Cleanup the other auth forms

Somewhat kinda progressively enhanced, but at least I'm using page partials now... mostly.
This commit is contained in:
Tyler Hallada 2023-12-19 01:18:39 -05:00
parent 7abffb2729
commit 6c23b3aaa3
8 changed files with 321 additions and 245 deletions

View File

@ -2,7 +2,7 @@ use axum::extract::{Query, State};
use axum::response::Response; use axum::response::Response;
use axum::{Form, TypedHeader}; use axum::{Form, TypedHeader};
use lettre::SmtpTransport; use lettre::SmtpTransport;
use maud::html; use maud::{html, Markup};
use serde::Deserialize; use serde::Deserialize;
use serde_with::{serde_as, NoneAsEmptyString}; use serde_with::{serde_as, NoneAsEmptyString};
use sqlx::PgPool; use sqlx::PgPool;
@ -24,6 +24,46 @@ pub struct ConfirmEmailQuery {
pub token_id: Option<Base62Uuid>, pub token_id: Option<Base62Uuid>,
} }
#[derive(Debug, Default)]
pub struct ConfirmEmailPageProps<'a> {
pub hx_target: Option<TypedHeader<HXTarget>>,
pub layout: Layout,
pub form_props: ConfirmEmailFormProps,
pub header: Option<&'a str>,
pub desc: Option<Markup>,
}
pub fn confirm_email_page(
ConfirmEmailPageProps {
hx_target,
layout,
form_props,
header,
desc,
}: ConfirmEmailPageProps,
) -> Response {
layout
.with_subtitle("confirm email")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { (header.unwrap_or("Confirm your email address")) }
}
@if let Some(desc) = desc {
(desc)
} @else {
p class="readable-width" {
"Enter your email to resend the confirmation email. If you don't have an account yet, create one "
a href="/register" { "here" }
"."
}
}
(confirm_email_form(form_props))
}
})
}
pub async fn get( pub async fn get(
State(pool): State<PgPool>, State(pool): State<PgPool>,
auth: AuthContext, auth: AuthContext,
@ -38,36 +78,33 @@ pub async fn get(
Err(err) => { Err(err) => {
if let Error::NotFoundUuid(_, _) = err { if let Error::NotFoundUuid(_, _) = err {
warn!(token_id = %token_id.as_uuid(), "token not found in database"); warn!(token_id = %token_id.as_uuid(), "token not found in database");
return Ok(layout return Ok(confirm_email_page(ConfirmEmailPageProps {
.with_subtitle("confirm email") hx_target,
.targeted(hx_target) layout,
.render(html! { form_props: ConfirmEmailFormProps::default(),
div class="center-horizontal" { header: Some("Email verification token not found"),
header class="center-text" { ..Default::default()
h2 { "Email verification token not found" } }));
}
p class="readable-width" { "Enter your email to resend the confirmation email. If you don't have an account yet, create one " a href="/register" { "here" } "." }
(confirm_email_form(ConfirmEmailFormProps::default()))
}
}));
} }
return Err(err); return Err(err);
} }
}; };
if token.expired() { if token.expired() {
warn!(token_id = %token.token_id, "token expired"); warn!(token_id = %token.token_id, "token expired");
Ok(layout Ok(confirm_email_page(ConfirmEmailPageProps {
.with_subtitle("confirm email") hx_target,
.targeted(hx_target) layout,
.render(html! { form_props: ConfirmEmailFormProps {
div class="center-horizontal" { token: Some(token),
header class="center-text" { ..Default::default()
h2 { "Email verification token is expired" } },
} header: Some("Email verification token is expired"),
p class="readable-width" { "Click the button below to resend a new confirmation email. The link in the email will be valid for another 24 hours."} desc: Some(html! {
(confirm_email_form(ConfirmEmailFormProps { token: Some(token), email: None })) p class="readable-width" {
"Click the button below to resend a new confirmation email. The link in the email will be valid for another 24 hours."
} }
})) }),
}))
} else { } else {
info!(token_id = %token.token_id, "token valid, verifying email"); info!(token_id = %token.token_id, "token valid, verifying email");
User::verify_email(&pool, token.user_id).await?; User::verify_email(&pool, token.user_id).await?;
@ -88,23 +125,15 @@ pub async fn get(
})) }))
} }
} else { } else {
Ok(layout Ok(confirm_email_page(ConfirmEmailPageProps {
.with_subtitle("confirm email") hx_target,
.targeted(hx_target) layout,
.render(html! { form_props: ConfirmEmailFormProps {
div class="center-horizontal" { email: auth.current_user.map(|u| u.email),
header class="center-text" { ..Default::default()
h2 { "Confirm your email address" } },
} ..Default::default()
p class="readable-width" { "An email was sent to your email address upon registration containing a link that will confirm your email address. If you can't find it or it has been more than 24 hours since it was sent, you can resend the email by submitting the form below:"} }))
(confirm_email_form(
ConfirmEmailFormProps {
token: None,
email: auth.current_user.map(|u| u.email),
}
))
}
}))
} }
} }
@ -172,21 +201,17 @@ pub async fn post(
} }
})); }));
} }
Ok(layout Ok(confirm_email_page(ConfirmEmailPageProps {
.with_subtitle("confirm email") hx_target,
.targeted(hx_target) layout,
.render(html! { form_props: ConfirmEmailFormProps::default(),
div class="center-horizontal" { header: Some("Email verification token not found"),
header class="center-text" { desc: Some(html! {
h2 { "Email verification token not found" } p class="readable-width" {
} "Enter your email to resend the confirmation email. If you don't have an account yet, create one "
p class="readable-width" { "Enter your email to resend the confirmation email." } a href="/register" { "here" }
p class="readable-width" { "."
"If you don't have an account yet, create one "
a href="/register" { "here" }
"."
}
(confirm_email_form(ConfirmEmailFormProps::default()))
} }
})) }),
}))
} }

View File

@ -46,6 +46,26 @@ pub fn forgot_password_page(
.into_response() .into_response()
} }
pub fn confirm_forgot_password_sent_page(
hx_target: Option<TypedHeader<HXTarget>>,
layout: Layout,
) -> Response {
layout
.with_subtitle("forgot password")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Reset password email sent" }
}
p class="readable-width" {
"If the email you entered matched an existing account with a verified email, then a password reset email was sent. Please follow the link sent in the email."
}
}
})
.into_response()
}
pub async fn get( pub async fn get(
auth: AuthContext, auth: AuthContext,
hx_target: Option<TypedHeader<HXTarget>>, hx_target: Option<TypedHeader<HXTarget>>,
@ -76,19 +96,7 @@ pub async fn post(
Err(err) => { Err(err) => {
if let Error::NotFoundString(_, _) = err { if let Error::NotFoundString(_, _) = err {
info!(email = forgot_password.email, "invalid email"); info!(email = forgot_password.email, "invalid email");
return Ok(layout return Ok(confirm_forgot_password_sent_page(hx_target, layout));
.with_subtitle("forgot password")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Reset password email sent" }
}
p class="readable-width" {
"If the email you entered matched an existing account with a verified email, then a password reset email was sent. Please follow the link sent in the email."
}
}
}));
} else { } else {
return Err(err); return Err(err);
} }
@ -107,17 +115,5 @@ pub async fn post(
} else { } else {
warn!(user_id = %user.user_id, "user exists with unverified email, skip sending password reset email"); warn!(user_id = %user.user_id, "user exists with unverified email, skip sending password reset email");
} }
Ok(layout Ok(confirm_forgot_password_sent_page(hx_target, layout))
.with_subtitle("forgot password")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Reset password email sent" }
}
p class="readable-width" {
"If the email you entered matched an existing account with a verified email, then a password reset email was sent. Please follow the link sent in the email."
}
}
}))
} }

View File

@ -47,7 +47,6 @@ pub fn register_page(
(register_form(form_props)) (register_form(form_props))
} }
}) })
.into_response()
} }
pub async fn get(hx_target: Option<TypedHeader<HXTarget>>, layout: Layout) -> Result<Response> { pub async fn get(hx_target: Option<TypedHeader<HXTarget>>, layout: Layout) -> Result<Response> {

View File

@ -1,5 +1,5 @@
use axum::extract::Query; use axum::extract::Query;
use axum::response::{IntoResponse, Response}; use axum::response::Response;
use axum::TypedHeader; use axum::TypedHeader;
use axum::{extract::State, Form}; use axum::{extract::State, Form};
use axum_client_ip::SecureClientIp; use axum_client_ip::SecureClientIp;
@ -36,26 +36,84 @@ pub struct ResetPasswordQuery {
pub token_id: Option<Base62Uuid>, pub token_id: Option<Base62Uuid>,
} }
pub fn reset_password_page( #[derive(Debug, Default)]
hx_target: Option<TypedHeader<HXTarget>>, pub struct InvalidTokenPageProps<'a> {
layout: Layout, pub hx_target: Option<TypedHeader<HXTarget>>,
form_props: ResetPasswordFormProps, pub layout: Layout,
pub header: Option<&'a str>,
pub desc: Option<&'a str>,
}
pub fn invalid_token_page(
InvalidTokenPageProps {
hx_target,
layout,
header,
desc,
}: InvalidTokenPageProps,
) -> Response { ) -> Response {
layout layout
.with_subtitle("forgot password") .with_subtitle("reset password")
.targeted(hx_target) .targeted(hx_target)
.render(html! { .render(html! {
div class="center-horizontal" { div class="center-horizontal" {
header class="center-text" { header class="center-text" {
h2 { "Reset Password" } h2 { (header.unwrap_or("Reset Password")) }
} }
p { @if let Some(desc) = desc {
p class="readable-width" { (desc) }
}
p class="readable-width" {
a href="/forgot-password" {
"Follow this link to request a new password reset email"
}
"."
}
}
})
}
#[derive(Debug, Default)]
pub struct ResetPasswordPageProps<'a> {
pub hx_target: Option<TypedHeader<HXTarget>>,
pub layout: Layout,
pub form_props: ResetPasswordFormProps,
pub header: Option<&'a str>,
pub post_form_error: Option<&'a str>,
}
pub fn reset_password_page(
ResetPasswordPageProps {
hx_target,
layout,
form_props,
header,
post_form_error,
}: ResetPasswordPageProps,
) -> Response {
layout
.with_subtitle("reset password")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { (header.unwrap_or("Reset Password")) }
}
p class="readable-width" {
"A password reset email will be sent if the email submitted matches an account in the system and the email is verfied. If your email is not verified, " a href="/confirm-email" { "please verify your email first" } "." "A password reset email will be sent if the email submitted matches an account in the system and the email is verfied. If your email is not verified, " a href="/confirm-email" { "please verify your email first" } "."
} }
(reset_password_form(form_props)) (reset_password_form(form_props))
@if let Some(post_form_error) = post_form_error {
p class="error readable-width" { (post_form_error) }
p class="readable-width" {
a href="/forgot-password" {
"Follow this link to request a new password reset email"
}
". The link in the email will be valid for 24 hours."
}
}
} }
}) })
.into_response()
} }
pub async fn get( pub async fn get(
@ -71,68 +129,45 @@ pub async fn get(
Err(err) => { Err(err) => {
if let Error::NotFoundUuid(_, _) = err { if let Error::NotFoundUuid(_, _) = err {
warn!(token_id = %token_id.as_uuid(), "token not found in database"); warn!(token_id = %token_id.as_uuid(), "token not found in database");
return Ok(layout return Ok(invalid_token_page(InvalidTokenPageProps {
.with_subtitle("reset password") hx_target,
.targeted(hx_target) layout,
.render(html! { header: Some("Password reset token not found"),
div class="center-horizontal" { desc: Some("The reset password link has already been used or is invalid."),
header class="center-text" { }));
h2 { "Password reset token not found" }
}
p class="readable-width" { "The reset password link has already been used or is invalid." }
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
}
}));
} }
return Err(err); return Err(err);
} }
}; };
if token.expired() { if token.expired() {
warn!(token_id = %token.token_id, "token expired"); warn!(token_id = %token.token_id, "token expired");
Ok(layout Ok(invalid_token_page(InvalidTokenPageProps {
.with_subtitle("reset password") hx_target,
.targeted(hx_target) layout,
.render(html! { header: Some("Password reset token expired"),
div class="center-horizontal" { ..Default::default()
header class="center-text" { }))
h2 { "Password reset token is expired" }
}
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } ". The link in the email will be valid for 24 hours." }
}
}))
} else { } else {
info!(token_id = %token.token_id, "token valid, showing reset password form"); info!(token_id = %token.token_id, "token valid, showing reset password form");
let user = User::get(&pool, token.user_id).await?; let user = User::get(&pool, token.user_id).await?;
Ok(layout Ok(reset_password_page(ResetPasswordPageProps {
.with_subtitle("reset password") hx_target,
.targeted(hx_target) layout,
.render(html! { form_props: ResetPasswordFormProps {
div class="center-horizontal" { token: token.token_id,
header class="center-text" { email: user.email,
h2 { "Reset Password" } ..Default::default()
} },
(reset_password_form(ResetPasswordFormProps { ..Default::default()
token: token.token_id, }))
email: user.email,
password_error: None,
general_error: None,
}))
}
}))
} }
} else { } else {
Ok(layout Ok(invalid_token_page(InvalidTokenPageProps {
.with_subtitle("reset password") hx_target,
.targeted(hx_target) layout,
.render(html! { header: Some("Missing password reset token"),
div class="center-horizontal" { desc: Some("Passwords can only be reset by requesting a password reset email and following the unique link within the email."),
header class="center-text" { }))
h2 { "Missing password reset token" }
}
p class="readable-width" { "Passwords can only be reset by requesting a password reset email and following the unique link within the email."}
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
}
}))
} }
} }
@ -147,94 +182,75 @@ pub async fn post(
Form(reset_password): Form<ResetPassword>, Form(reset_password): Form<ResetPassword>,
) -> Result<Response> { ) -> Result<Response> {
if reset_password.password != reset_password.password_confirmation { if reset_password.password != reset_password.password_confirmation {
return Ok(layout return Ok(reset_password_page(ResetPasswordPageProps {
.with_subtitle("reset password") hx_target,
.targeted(hx_target) layout,
.render(html! { form_props: ResetPasswordFormProps {
div class="center-horizontal" { token: reset_password.token,
header class="center-text" { email: reset_password.email,
h2 { "Reset Password" } password_error: Some("passwords do not match".to_string()),
} ..Default::default()
(reset_password_form(ResetPasswordFormProps { },
token: reset_password.token, ..Default::default()
email: reset_password.email, }));
password_error: Some("passwords do not match".to_string()),
general_error: None,
}))
}
}));
} }
let token = match UserPasswordResetToken::get(&pool, reset_password.token).await { let token = match UserPasswordResetToken::get(&pool, reset_password.token).await {
Ok(token) => token, Ok(token) => token,
Err(err) => { Err(err) => {
if let Error::NotFoundUuid(_, _) = err { if let Error::NotFoundUuid(_, _) = err {
warn!(token_id = %reset_password.token, "token not found in database"); warn!(token_id = %reset_password.token, "token not found in database");
return Ok(layout return Ok(reset_password_page(ResetPasswordPageProps {
.with_subtitle("reset password") hx_target,
.targeted(hx_target) layout,
.render(html! { form_props: ResetPasswordFormProps {
div class="center-horizontal" { token: reset_password.token,
header class="center-text" { email: reset_password.email,
h2 { "Reset Password" } general_error: Some("token not found".to_string()),
} ..Default::default()
(reset_password_form(ResetPasswordFormProps { },
token: reset_password.token, post_form_error: Some(
email: reset_password.email, "The reset password link has already been used or is invalid.",
password_error: None, ),
general_error: Some("token not found".to_string()), ..Default::default()
})) }));
p class="error readable-width" { "The reset password link has already been used or is invalid." }
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
}
}));
} }
return Err(err); return Err(err);
} }
}; };
if token.expired() { if token.expired() {
warn!(token_id = %token.token_id, "token expired"); warn!(token_id = %token.token_id, "token expired");
return Ok(layout return Ok(reset_password_page(ResetPasswordPageProps {
.with_subtitle("reset password") hx_target,
.targeted(hx_target) layout,
.render(html! { form_props: ResetPasswordFormProps {
div class="center-horizontal" { token: reset_password.token,
header class="center-text" { email: reset_password.email,
h2 { "Reset Password" } general_error: Some("token expired".to_string()),
} ..Default::default()
(reset_password_form(ResetPasswordFormProps { },
token: reset_password.token, post_form_error: Some("The reset password link has expired."),
email: reset_password.email, ..Default::default()
password_error: None, }));
general_error: Some("token expired".to_string()),
}))
p class="error readable-width" { "The reset password link has expired." }
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } ". The link in the email will be valid for 24 hours." }
}
}));
} }
let user = match User::get(&pool, token.user_id).await { let user = match User::get(&pool, token.user_id).await {
Ok(user) => user, Ok(user) => user,
Err(err) => { Err(err) => {
if let Error::NotFoundString(_, _) = err { if let Error::NotFoundString(_, _) = err {
info!(user_id = %token.user_id, email = reset_password.email, "invalid token user_id"); info!(user_id = %token.user_id, email = reset_password.email, "invalid token user_id");
return Ok(layout return Ok(reset_password_page(ResetPasswordPageProps {
.with_subtitle("reset password") hx_target,
.targeted(hx_target) layout,
.render(html! { form_props: ResetPasswordFormProps {
div class="center-horizontal" { token: reset_password.token,
header class="center-text" { email: reset_password.email,
h2 { "Reset Password" } general_error: Some("user not found".to_string()),
} ..Default::default()
(reset_password_form(ResetPasswordFormProps { },
token: reset_password.token, post_form_error: Some(
email: reset_password.email, "The user associated with this password reset could not be found.",
password_error: None, ),
general_error: Some("user not found".to_string()), ..Default::default()
})) }));
p class="error readable-width" { "The user associated with this password reset could not be found." }
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
}
}));
} else { } else {
return Err(err); return Err(err);
} }
@ -256,28 +272,23 @@ 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(layout return Ok(reset_password_page(ResetPasswordPageProps {
.with_subtitle("reset password") hx_target,
.targeted(hx_target) layout,
.render(html! { form_props: ResetPasswordFormProps {
div class="center-horizontal" { token: reset_password.token,
header class="center-text" { email: reset_password.email,
h2 { "Reset Password" } password_error: field_errors.get("password").map(|&errors| {
} errors
(reset_password_form(ResetPasswordFormProps { .iter()
token: reset_password.token, .filter_map(|error| error.message.clone().map(|m| m.to_string()))
email: reset_password.email, .collect::<Vec<String>>()
password_error: field_errors.get("password").map(|&errors| { .join(", ")
errors }),
.iter() ..Default::default()
.filter_map(|error| error.message.clone().map(|m| m.to_string())) },
.collect::<Vec<String>>() ..Default::default()
.join(", ") }));
}),
general_error: None,
}))
}
}));
} }
return Err(err); return Err(err);
} }

View File

@ -11,7 +11,13 @@ pub struct ConfirmEmailFormProps {
pub fn confirm_email_form(props: ConfirmEmailFormProps) -> Markup { pub fn confirm_email_form(props: ConfirmEmailFormProps) -> Markup {
let ConfirmEmailFormProps { token, email } = props; let ConfirmEmailFormProps { token, email } = props;
html! { html! {
form action="/confirm-email" method="post" class="auth-form-grid" { form
action="/confirm-email"
method="post"
hx-post="/confirm-email"
id="confirm-email-form"
class="auth-form-grid"
{
input input
type="text" type="text"
name="token" name="token"

View File

@ -9,7 +9,13 @@ pub struct ForgotPasswordFormProps {
pub fn forgot_password_form(props: ForgotPasswordFormProps) -> Markup { pub fn forgot_password_form(props: ForgotPasswordFormProps) -> Markup {
let ForgotPasswordFormProps { email, email_error } = props; let ForgotPasswordFormProps { email, email_error } = props;
html! { html! {
form action="forgot-password" method="post" class="auth-form-grid" { form
action="/forgot-password"
method="post"
hx-post="/forgot-password"
id="forgot-password-form"
class="auth-form-grid"
{
label for="email" { "Email" } label for="email" { "Email" }
input input
type="email" type="email"

View File

@ -2,10 +2,20 @@ use maud::{html, Markup, PreEscaped};
pub fn opml_import_form() -> Markup { pub fn opml_import_form() -> Markup {
html! { html! {
form id="opml-import-form" hx-post="/import/opml" hx-swap="outerHTML" hx-encoding="multipart/form-data" class="feed-form" { form
id="opml-import-form"
hx-post="/import/opml"
hx-encoding="multipart/form-data"
class="feed-form"
{
div class="form-grid" { div class="form-grid" {
label for="opml" { "OPML: " } label for="opml" { "OPML: " }
input type="file" id="opml" name="opml" required="true" accept="text/x-opml,application/xml,text/xml"; input
type="file"
id="opml"
name="opml"
required="true"
accept="text/x-opml,application/xml,text/xml";
button type="submit" { "Import Feeds" } button type="submit" { "Import Feeds" }
progress id="opml-upload-progress" max="100" value="0" hidden="true" {} progress id="opml-upload-progress" max="100" value="0" hidden="true" {}
} }

View File

@ -1,7 +1,7 @@
use maud::{html, Markup}; use maud::{html, Markup};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone)] #[derive(Debug, Clone, Default)]
pub struct ResetPasswordFormProps { pub struct ResetPasswordFormProps {
pub token: Uuid, pub token: Uuid,
pub email: String, pub email: String,
@ -10,9 +10,20 @@ pub struct ResetPasswordFormProps {
} }
pub fn reset_password_form(props: ResetPasswordFormProps) -> Markup { pub fn reset_password_form(props: ResetPasswordFormProps) -> Markup {
let ResetPasswordFormProps { token, email, password_error, general_error } = props; let ResetPasswordFormProps {
token,
email,
password_error,
general_error,
} = props;
html! { html! {
form action="reset-password" method="post" class="auth-form-grid" { form
action="/reset-password"
method="post"
hx-post="/reset-password"
id="reset-password-form"
class="auth-form-grid"
{
input input
type="text" type="text"
name="token" name="token"
@ -28,12 +39,24 @@ pub fn reset_password_form(props: ResetPasswordFormProps) -> Markup {
value=(email) value=(email)
required; required;
label for="password" { "Password" } label for="password" { "Password" }
input type="password" name="password" id="password" placeholder="Password" minlength="8" maxlength="255" required; input
type="password"
name="password"
id="password"
placeholder="Password"
minlength="8"
maxlength="255"
required;
@if let Some(password_error) = password_error { @if let Some(password_error) = password_error {
span class="error" { (password_error) } span class="error" { (password_error) }
} }
label for="password_confirmation" { "Confirm Password" } label for="password_confirmation" { "Confirm Password" }
input type="password" name="password_confirmation" id="password_confirmation" placeholder="Confirm Password" required; input
type="password"
name="password_confirmation"
id="password_confirmation"
placeholder="Confirm Password"
required;
button type="submit" { "Reset password" } button type="submit" { "Reset password" }
@if let Some(general_error) = general_error { @if let Some(general_error) = general_error {
span class="error" { (general_error) } span class="error" { (general_error) }