Fixes to email verification process

Also make registration form progressively enhanced.
This commit is contained in:
Tyler Hallada 2023-10-06 17:50:08 -04:00
parent e59c6d596e
commit 609f6d3d9f
7 changed files with 106 additions and 58 deletions

View File

@ -1,7 +1,7 @@
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use axum::response::Response; use axum::response::Response;
use axum::{Form, TypedHeader}; use axum::{Form, TypedHeader};
use axum_login::{extractors::AuthContext, SqlxStore}; use axum_login::SqlxStore;
use lettre::SmtpTransport; use lettre::SmtpTransport;
use maud::html; use maud::html;
use serde::Deserialize; use serde::Deserialize;
@ -14,7 +14,7 @@ use crate::config::Config;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::htmx::HXTarget; use crate::htmx::HXTarget;
use crate::mailers::email_verification::send_confirmation_email; use crate::mailers::email_verification::send_confirmation_email;
use crate::models::user::User; use crate::models::user::{AuthContext, User};
use crate::models::user_email_verification_token::UserEmailVerificationToken; use crate::models::user_email_verification_token::UserEmailVerificationToken;
use crate::partials::confirm_email_form::{confirm_email_form, ConfirmEmailFormProps}; use crate::partials::confirm_email_form::{confirm_email_form, ConfirmEmailFormProps};
use crate::partials::layout::Layout; use crate::partials::layout::Layout;
@ -27,7 +27,7 @@ pub struct ConfirmEmailQuery {
pub async fn get( pub async fn get(
State(pool): State<PgPool>, State(pool): State<PgPool>,
auth: AuthContext<Uuid, User, SqlxStore<PgPool, User>>, auth: AuthContext,
hx_target: Option<TypedHeader<HXTarget>>, hx_target: Option<TypedHeader<HXTarget>>,
layout: Layout, layout: Layout,
query: Query<ConfirmEmailQuery>, query: Query<ConfirmEmailQuery>,

View File

@ -91,5 +91,5 @@ pub async fn post(
auth.login(&user) auth.login(&user)
.await .await
.map_err(|_| Error::InternalServerError)?; .map_err(|_| Error::InternalServerError)?;
Ok(HXRedirect::to("/").into_response()) Ok(HXRedirect::to("/").reload(true).into_response())
} }

View File

@ -44,17 +44,27 @@ pub async fn post(
State(mailer): State<SmtpTransport>, State(mailer): State<SmtpTransport>,
State(config): State<Config>, State(config): State<Config>,
mut auth: AuthContext, mut auth: AuthContext,
hx_target: Option<TypedHeader<HXTarget>>,
layout: Layout,
Form(register): Form<Register>, Form(register): Form<Register>,
) -> Result<Response> { ) -> Result<Response> {
if register.password != register.password_confirmation { if register.password != register.password_confirmation {
// return Err(Error::BadRequest("passwords do not match")); return Ok(layout
return Ok(register_form(RegisterFormProps { .with_subtitle("register")
email: Some(register.email), .targeted(hx_target)
name: register.name, .render(html! {
password_error: Some("passwords do not match".to_string()), div class="center-horizontal" {
..Default::default() header class="center-text" {
}) h2 { "Register" }
.into_response()); }
(register_form(RegisterFormProps {
email: Some(register.email),
name: register.name,
password_error: Some("passwords do not match".to_string()),
..Default::default()
}))
}
}));
} }
let user = match User::create( let user = match User::create(
&pool, &pool,
@ -70,44 +80,62 @@ pub async fn post(
Err(err) => { Err(err) => {
if let Error::InvalidEntity(validation_errors) = err { if let Error::InvalidEntity(validation_errors) = err {
let field_errors = validation_errors.field_errors(); let field_errors = validation_errors.field_errors();
return Ok(register_form(RegisterFormProps { return Ok(layout
email: Some(register.email), .with_subtitle("register")
name: register.name, .targeted(hx_target)
email_error: field_errors.get("email").map(|&errors| { .render(html! {
errors div class="center-horizontal" {
.iter() header class="center-text" {
.filter_map(|error| error.message.clone().map(|m| m.to_string())) h2 { "Register" }
.collect::<Vec<String>>() }
.join(", ") (register_form(RegisterFormProps {
}), email: Some(register.email),
name_error: field_errors.get("name").map(|&errors| { name: register.name,
errors email_error: field_errors.get("email").map(|&errors| {
.iter() errors
.filter_map(|error| error.message.clone().map(|m| m.to_string())) .iter()
.collect::<Vec<String>>() .filter_map(|error| error.message.clone().map(|m| m.to_string()))
.join(", ") .collect::<Vec<String>>()
}), .join(", ")
password_error: field_errors.get("password").map(|&errors| { }),
errors name_error: field_errors.get("name").map(|&errors| {
.iter() errors
.filter_map(|error| error.message.clone().map(|m| m.to_string())) .iter()
.collect::<Vec<String>>() .filter_map(|error| error.message.clone().map(|m| m.to_string()))
.join(", ") .collect::<Vec<String>>()
}), .join(", ")
..Default::default() }),
}) password_error: field_errors.get("password").map(|&errors| {
.into_response()); errors
.iter()
.filter_map(|error| error.message.clone().map(|m| m.to_string()))
.collect::<Vec<String>>()
.join(", ")
}),
..Default::default()
}))
}
}));
} }
if let Error::Sqlx(sqlx::error::Error::Database(db_error)) = &err { if let Error::Sqlx(sqlx::error::Error::Database(db_error)) = &err {
if let Some(constraint) = db_error.constraint() { if let Some(constraint) = db_error.constraint() {
if constraint == "users_email_idx" { if constraint == "users_email_idx" {
return Ok(register_form(RegisterFormProps { return Ok(layout
email: Some(register.email), .with_subtitle("register")
name: register.name, .targeted(hx_target)
email_error: Some("email already exists".to_string()), .render(html! {
..Default::default() div class="center-horizontal" {
}) header class="center-text" {
.into_response()); h2 { "Register" }
}
(register_form(RegisterFormProps {
email: Some(register.email),
name: register.name,
email_error: Some("email already exists".to_string()),
..Default::default()
}))
}
}));
} }
} }
} }
@ -120,5 +148,5 @@ pub async fn post(
auth.login(&user) auth.login(&user)
.await .await
.map_err(|_| Error::InternalServerError)?; .map_err(|_| Error::InternalServerError)?;
Ok(HXRedirect::to("/").into_response()) Ok(HXRedirect::to("/").reload(true).into_response())
} }

View File

@ -3,6 +3,8 @@ use headers::{self, Header};
use http::header::{HeaderName, HeaderValue}; use http::header::{HeaderName, HeaderValue};
use http::StatusCode; use http::StatusCode;
#[allow(clippy::declare_interior_mutable_const)]
pub const HX_REDIRECT: HeaderName = HeaderName::from_static("hx-redirect");
#[allow(clippy::declare_interior_mutable_const)] #[allow(clippy::declare_interior_mutable_const)]
pub const HX_LOCATION: HeaderName = HeaderName::from_static("hx-location"); pub const HX_LOCATION: HeaderName = HeaderName::from_static("hx-location");
#[allow(clippy::declare_interior_mutable_const)] #[allow(clippy::declare_interior_mutable_const)]
@ -16,23 +18,38 @@ pub const HX_TARGET: HeaderName = HeaderName::from_static("hx-target");
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct HXRedirect { pub struct HXRedirect {
location: HeaderValue, location: HeaderValue,
reload: bool,
} }
impl HXRedirect { impl HXRedirect {
pub fn to(uri: &str) -> Self { pub fn to(uri: &str) -> Self {
Self { Self {
location: HeaderValue::try_from(uri).expect("URI isn't a valid header value"), location: HeaderValue::try_from(uri).expect("URI isn't a valid header value"),
reload: false,
} }
} }
pub fn reload(mut self, reload: bool) -> Self {
self.reload = reload;
self
}
} }
impl IntoResponse for HXRedirect { impl IntoResponse for HXRedirect {
fn into_response(self) -> Response { fn into_response(self) -> Response {
( if self.reload {
StatusCode::OK, (
[(HX_LOCATION, self.location)], StatusCode::OK,
) [(HX_REDIRECT, self.location)],
.into_response() )
.into_response()
} else {
(
StatusCode::OK,
[(HX_LOCATION, self.location)],
)
.into_response()
}
} }
} }

View File

@ -14,7 +14,12 @@ pub fn header(title: &str, user: Option<User>) -> Markup {
} }
div class="auth" { div class="auth" {
@if let Some(user) = user { @if let Some(user) = user {
(user_name(user)) (user_name(user.clone()))
@if !user.email_verified {
span { " (" }
a href="/confirm-email" { "unverified" }
span { ")" }
}
span { " | " } span { " | " }
a href="/logout" { "logout" } a href="/logout" { "logout" }
} @else { } @else {

View File

@ -10,12 +10,10 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
TypedHeader, TypedHeader,
}; };
use axum_login::{extractors::AuthContext, SqlxStore};
use headers::HeaderValue; use headers::HeaderValue;
use maud::{html, Markup, DOCTYPE}; use maud::{html, Markup, DOCTYPE};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::user::AuthContext;
use crate::models::user::User; use crate::models::user::User;
use crate::config::Config; use crate::config::Config;
use crate::htmx::HXTarget; use crate::htmx::HXTarget;
@ -45,7 +43,7 @@ where
.await .await
.map_err(|err| err.into_response())?; .map_err(|err| err.into_response())?;
let auth_context = let auth_context =
AuthContext::<Uuid, User, SqlxStore<PgPool, User>>::from_request_parts(parts, state) AuthContext::from_request_parts(parts, state)
.await .await
.map_err(|err| err.into_response())?; .map_err(|err| err.into_response())?;
Ok(Self { Ok(Self {

View File

@ -20,7 +20,7 @@ pub fn register_form(props: RegisterFormProps) -> Markup {
general_error, general_error,
} = props; } = props;
html! { html! {
form hx-post="/register" hx-swap="outerHTML" class="auth-form-grid" { form action="/register" method="post" class="auth-form-grid" {
label for="email" { "Email *" } label for="email" { "Email *" }
input type="email" name="email" id="email" placeholder="Email" value=(email.unwrap_or_default()) required; input type="email" name="email" id="email" placeholder="Email" value=(email.unwrap_or_default()) required;
@if let Some(email_error) = email_error { @if let Some(email_error) = email_error {