From 89597dbf81f33dd2670401fde22617a962c2f396 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 24 May 2022 12:31:05 +0200 Subject: [PATCH] Switch email verification to a code-based flow --- crates/data-model/src/users.rs | 3 + crates/email/src/mailer.rs | 8 +- crates/handlers/src/lib.rs | 5 +- .../account/{emails.rs => emails/mod.rs} | 47 +++---- .../src/views/account/emails/verify.rs | 119 ++++++++++++++++++ crates/handlers/src/views/mod.rs | 1 - crates/handlers/src/views/shared.rs | 14 ++- crates/handlers/src/views/verify.rs | 60 --------- crates/router/src/endpoints.rs | 38 +++--- crates/router/src/url_builder.rs | 21 ---- crates/storage/src/user.rs | 79 ++++++++---- crates/templates/src/context.rs | 91 ++++++++++++-- crates/templates/src/lib.rs | 21 ++-- .../templates/src/res/components/field.html | 74 +++++------ .../src/res/emails/verification.html | 6 +- .../verification.subject} | 10 +- .../templates/src/res/emails/verification.txt | 6 +- .../src/res/pages/account/verify.html | 42 +++++++ 18 files changed, 409 insertions(+), 236 deletions(-) rename crates/handlers/src/views/account/{emails.rs => emails/mod.rs} (82%) create mode 100644 crates/handlers/src/views/account/emails/verify.rs delete mode 100644 crates/handlers/src/views/verify.rs rename crates/templates/src/res/{pages/verify.html => emails/verification.subject} (74%) create mode 100644 crates/templates/src/res/pages/account/verify.html diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index b411f43e..12bca9b2 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -173,6 +173,7 @@ pub enum UserEmailVerificationState { pub struct UserEmailVerification { pub data: T::UserEmailVerificationData, pub email: UserEmail, + pub code: String, pub created_at: DateTime, pub state: UserEmailVerificationState, } @@ -182,6 +183,7 @@ impl From> for UserEmailVerifi Self { data: (), email: v.email.into(), + code: v.code, created_at: v.created_at, state: v.state, } @@ -207,6 +209,7 @@ where .flat_map(|state| { UserEmail::samples().into_iter().map(move |email| Self { data: Default::default(), + code: "123456".to_string(), email, created_at: Utc::now() - Duration::minutes(10), state: state.clone(), diff --git a/crates/email/src/mailer.rs b/crates/email/src/mailer.rs index 7bf16ab1..1671c846 100644 --- a/crates/email/src/mailer.rs +++ b/crates/email/src/mailer.rs @@ -71,10 +71,14 @@ impl Mailer { let multipart = MultiPart::alternative_plain_html(plain, html); + let subject = self + .templates + .render_email_verification_subject(context) + .await?; + let message = self .base_message() - // TODO: template/localize this - .subject("Verify your email address") + .subject(subject.trim()) .to(to) .multipart(multipart)?; diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 635ed10d..3d81d18a 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -159,8 +159,9 @@ where get(self::views::register::get).post(self::views::register::post), ) .route( - mas_router::VerifyEmail::route(), - get(self::views::verify::get), + mas_router::AccountVerifyEmail::route(), + get(self::views::account::emails::verify::get) + .post(self::views::account::emails::verify::post), ) .route(mas_router::Account::route(), get(self::views::account::get)) .route( diff --git a/crates/handlers/src/views/account/emails.rs b/crates/handlers/src/views/account/emails/mod.rs similarity index 82% rename from crates/handlers/src/views/account/emails.rs rename to crates/handlers/src/views/account/emails/mod.rs index f172efa5..854339ec 100644 --- a/crates/handlers/src/views/account/emails.rs +++ b/crates/handlers/src/views/account/emails/mod.rs @@ -25,7 +25,7 @@ use mas_axum_utils::{ use mas_config::Encrypter; use mas_data_model::{BrowserSession, User, UserEmail}; use mas_email::Mailer; -use mas_router::{Route, UrlBuilder}; +use mas_router::Route; use mas_storage::{ user::{ add_user_email, add_user_email_verification_code, get_user_email, get_user_emails, @@ -34,16 +34,17 @@ use mas_storage::{ PostgresqlBackend, }; use mas_templates::{AccountEmailsContext, EmailVerificationContext, TemplateContext, Templates}; -use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use rand::{distributions::Uniform, thread_rng, Rng}; use serde::Deserialize; use sqlx::{PgExecutor, PgPool}; use tracing::info; +pub mod verify; + #[derive(Deserialize, Debug)] #[serde(tag = "action", rename_all = "snake_case")] pub enum ManagementForm { Add { email: String }, - ResendConfirmation { data: String }, SetPrimary { data: String }, Remove { data: String }, } @@ -88,39 +89,35 @@ async fn render( async fn start_email_verification( mailer: &Mailer, - url_builder: &UrlBuilder, executor: impl PgExecutor<'_>, user: &User, - user_email: &UserEmail, + user_email: UserEmail, ) -> anyhow::Result<()> { // First, generate a code - let code: String = thread_rng() - .sample_iter(&Alphanumeric) - .take(32) - .map(char::from) - .collect(); + let range = Uniform::::from(0..1_000_000); + let code = thread_rng().sample(range).to_string(); - add_user_email_verification_code(executor, user_email, &code).await?; - - // And send the verification email let address: Address = user_email.email.parse()?; + let verification = add_user_email_verification_code(executor, user_email, code).await?; + + // And send the verification email let mailbox = Mailbox::new(Some(user.username.clone()), address); - let link = url_builder.email_verification(code); - - let context = EmailVerificationContext::new(user.clone().into(), link); + let context = EmailVerificationContext::new(user.clone().into(), verification.clone().into()); mailer.send_verification_email(mailbox, &context).await?; - info!(email.id = user_email.data, "Verification email sent"); + info!( + email.id = verification.email.data, + "Verification email sent" + ); Ok(()) } pub(crate) async fn post( Extension(templates): Extension, Extension(pool): Extension, - Extension(url_builder): Extension, Extension(mailer): Extension, cookie_jar: PrivateCookieJar, Form(form): Form>, @@ -143,8 +140,10 @@ pub(crate) async fn post( match form { ManagementForm::Add { email } => { let user_email = add_user_email(&mut txn, &session.user, email).await?; - start_email_verification(&mailer, &url_builder, &mut txn, &session.user, &user_email) - .await?; + let next = mas_router::AccountVerifyEmail(user_email.data); + start_email_verification(&mailer, &mut txn, &session.user, user_email).await?; + txn.commit().await?; + return Ok((cookie_jar, next.go()).into_response()); } ManagementForm::Remove { data } => { let id = data.parse()?; @@ -152,14 +151,6 @@ pub(crate) async fn post( let email = get_user_email(&mut txn, &session.user, id).await?; remove_user_email(&mut txn, email).await?; } - ManagementForm::ResendConfirmation { data } => { - let id = data.parse()?; - - let user_email = get_user_email(&mut txn, &session.user, id).await?; - - start_email_verification(&mailer, &url_builder, &mut txn, &session.user, &user_email) - .await?; - } ManagementForm::SetPrimary { data } => { let id = data.parse()?; let email = get_user_email(&mut txn, &session.user, id).await?; diff --git a/crates/handlers/src/views/account/emails/verify.rs b/crates/handlers/src/views/account/emails/verify.rs new file mode 100644 index 00000000..64ead17d --- /dev/null +++ b/crates/handlers/src/views/account/emails/verify.rs @@ -0,0 +1,119 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use axum::{ + extract::{Extension, Form, Path, Query}, + response::{Html, IntoResponse, Response}, +}; +use axum_extra::extract::PrivateCookieJar; +use chrono::Duration; +use mas_axum_utils::{ + csrf::{CsrfExt, ProtectedForm}, + FancyError, SessionInfoExt, +}; +use mas_config::Encrypter; +use mas_router::Route; +use mas_storage::user::{ + consume_email_verification, lookup_user_email_by_id, lookup_user_email_verification_code, + mark_user_email_as_verified, +}; +use mas_templates::{EmailVerificationPageContext, TemplateContext, Templates}; +use serde::Deserialize; +use sqlx::PgPool; + +use crate::views::shared::OptionalPostAuthAction; + +#[derive(Deserialize, Debug)] +pub struct CodeForm { + code: String, +} + +pub(crate) async fn get( + Extension(templates): Extension, + Extension(pool): Extension, + Query(query): Query, + Path(id): Path, + cookie_jar: PrivateCookieJar, +) -> Result { + let mut txn = pool.begin().await?; + + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(); + let (session_info, cookie_jar) = cookie_jar.session_info(); + + let maybe_session = session_info.load_session(&mut txn).await?; + + let session = if let Some(session) = maybe_session { + session + } else { + let login = mas_router::Login::default(); + return Ok((cookie_jar, login.go()).into_response()); + }; + + let user_email = lookup_user_email_by_id(&mut txn, &session.user, id).await?; + + if user_email.confirmed_at.is_some() { + // This email was already verified, skip + let destination = query.go_next_or_default(&mas_router::AccountEmails); + return Ok((cookie_jar, destination).into_response()); + } + + let ctx = EmailVerificationPageContext::new(user_email) + .with_session(session) + .with_csrf(csrf_token.form_value()); + + let content = templates.render_email_verification_form(&ctx).await?; + + txn.commit().await?; + + Ok((cookie_jar, Html(content)).into_response()) +} + +pub(crate) async fn post( + Extension(pool): Extension, + cookie_jar: PrivateCookieJar, + Query(query): Query, + Path(id): Path, + Form(form): Form>, +) -> Result { + let mut txn = pool.begin().await?; + + let form = cookie_jar.verify_form(form)?; + let (session_info, cookie_jar) = cookie_jar.session_info(); + + let maybe_session = session_info.load_session(&mut txn).await?; + + let session = if let Some(session) = maybe_session { + session + } else { + let login = mas_router::Login::default(); + return Ok((cookie_jar, login.go()).into_response()); + }; + + let email = lookup_user_email_by_id(&mut txn, &session.user, id).await?; + + // TODO: make those 8 hours configurable + let verification = + lookup_user_email_verification_code(&mut txn, email, &form.code, Duration::hours(8)) + .await?; + + // TODO: display nice errors if the code was already consumed or expired + let verification = consume_email_verification(&mut txn, verification).await?; + + let _email = mark_user_email_as_verified(&mut txn, verification.email).await?; + + txn.commit().await?; + + let destination = query.go_next_or_default(&mas_router::AccountEmails); + Ok((cookie_jar, destination).into_response()) +} diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index 898ad64b..ee0ed4ad 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -19,4 +19,3 @@ pub mod logout; pub mod reauth; pub mod register; pub mod shared; -pub mod verify; diff --git a/crates/handlers/src/views/shared.rs b/crates/handlers/src/views/shared.rs index f9274edc..502892dc 100644 --- a/crates/handlers/src/views/shared.rs +++ b/crates/handlers/src/views/shared.rs @@ -27,12 +27,16 @@ pub(crate) struct OptionalPostAuthAction { } impl OptionalPostAuthAction { - pub fn go_next(&self) -> axum::response::Redirect { - self.post_auth_action.as_ref().map_or_else( - || mas_router::Index.go(), - mas_router::PostAuthAction::go_next, - ) + pub fn go_next_or_default(&self, default: &T) -> axum::response::Redirect { + self.post_auth_action + .as_ref() + .map_or_else(|| default.go(), mas_router::PostAuthAction::go_next) } + + pub fn go_next(&self) -> axum::response::Redirect { + self.go_next_or_default(&mas_router::Index) + } + pub async fn load_context<'e>( &self, conn: &mut PgConnection, diff --git a/crates/handlers/src/views/verify.rs b/crates/handlers/src/views/verify.rs deleted file mode 100644 index 0799f68e..00000000 --- a/crates/handlers/src/views/verify.rs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2022 The Matrix.org Foundation C.I.C. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use axum::{ - extract::{Extension, Path}, - response::{Html, IntoResponse}, -}; -use axum_extra::extract::PrivateCookieJar; -use chrono::Duration; -use mas_axum_utils::{csrf::CsrfExt, FancyError, SessionInfoExt}; -use mas_config::Encrypter; -use mas_storage::user::{ - consume_email_verification, lookup_user_email_verification_code, mark_user_email_as_verified, -}; -use mas_templates::{EmptyContext, TemplateContext, Templates}; -use sqlx::PgPool; - -pub(crate) async fn get( - Extension(templates): Extension, - Extension(pool): Extension, - Path(code): Path, - cookie_jar: PrivateCookieJar, -) -> Result { - let mut txn = pool.begin().await?; - - // TODO: make those 8 hours configurable - let verification = - lookup_user_email_verification_code(&mut txn, &code, Duration::hours(8)).await?; - - // TODO: display nice errors if the code was already consumed or expired - let verification = consume_email_verification(&mut txn, verification).await?; - - let _email = mark_user_email_as_verified(&mut txn, verification.email).await?; - - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(); - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut txn).await?; - - let ctx = EmptyContext - .maybe_with_session(maybe_session) - .with_csrf(csrf_token.form_value()); - - let content = templates.render_email_verification_done(&ctx).await?; - - txn.commit().await?; - - Ok((cookie_jar, Html(content))) -} diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 51320802..c7f5bacc 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -52,7 +52,7 @@ impl PostAuthAction { } /// `GET /.well-known/openid-configuration` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct OidcConfiguration; impl SimpleRoute for OidcConfiguration { @@ -60,7 +60,7 @@ impl SimpleRoute for OidcConfiguration { } /// `GET /.well-known/webfinger` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct Webfinger; impl SimpleRoute for Webfinger { @@ -75,7 +75,7 @@ impl SimpleRoute for ChangePasswordDiscovery { } /// `GET /oauth2/keys.json` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct OAuth2Keys; impl SimpleRoute for OAuth2Keys { @@ -83,7 +83,7 @@ impl SimpleRoute for OAuth2Keys { } /// `GET /oauth2/userinfo` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct OidcUserinfo; impl SimpleRoute for OidcUserinfo { @@ -91,7 +91,7 @@ impl SimpleRoute for OidcUserinfo { } /// `POST /oauth2/userinfo` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct OAuth2Introspection; impl SimpleRoute for OAuth2Introspection { @@ -99,7 +99,7 @@ impl SimpleRoute for OAuth2Introspection { } /// `POST /oauth2/token` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct OAuth2TokenEndpoint; impl SimpleRoute for OAuth2TokenEndpoint { @@ -107,7 +107,7 @@ impl SimpleRoute for OAuth2TokenEndpoint { } /// `POST /oauth2/registration` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct OAuth2RegistrationEndpoint; impl SimpleRoute for OAuth2RegistrationEndpoint { @@ -115,7 +115,7 @@ impl SimpleRoute for OAuth2RegistrationEndpoint { } /// `GET /authorize` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct OAuth2AuthorizationEndpoint; impl SimpleRoute for OAuth2AuthorizationEndpoint { @@ -123,7 +123,7 @@ impl SimpleRoute for OAuth2AuthorizationEndpoint { } /// `GET /` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct Index; impl SimpleRoute for Index { @@ -131,7 +131,7 @@ impl SimpleRoute for Index { } /// `GET /health` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct Healthcheck; impl SimpleRoute for Healthcheck { @@ -200,7 +200,7 @@ impl From> for Login { } /// `POST /logout` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct Logout; impl SimpleRoute for Logout { @@ -315,23 +315,23 @@ impl From> for Register { } } -/// `GET /verify/:code` +/// `GET /account/emails/verify/:id` #[derive(Debug, Clone)] -pub struct VerifyEmail(pub String); +pub struct AccountVerifyEmail(pub i64); -impl Route for VerifyEmail { +impl Route for AccountVerifyEmail { type Query = (); fn route() -> &'static str { - "/verify/:code" + "/account/emails/verify/:id" } fn path(&self) -> std::borrow::Cow<'static, str> { - format!("/verify/{}", self.0).into() + format!("/account/emails/verify/{}", self.0).into() } } /// `GET /account` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct Account; impl SimpleRoute for Account { @@ -339,7 +339,7 @@ impl SimpleRoute for Account { } /// `GET|POST /account/password` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct AccountPassword; impl SimpleRoute for AccountPassword { @@ -347,7 +347,7 @@ impl SimpleRoute for AccountPassword { } /// `GET|POST /account/emails` -#[derive(Debug, Clone)] +#[derive(Default, Debug, Clone)] pub struct AccountEmails; impl SimpleRoute for AccountEmails { diff --git a/crates/router/src/url_builder.rs b/crates/router/src/url_builder.rs index 5c0098e5..5453061c 100644 --- a/crates/router/src/url_builder.rs +++ b/crates/router/src/url_builder.rs @@ -91,25 +91,4 @@ impl UrlBuilder { pub fn jwks_uri(&self) -> Url { self.url_for(&crate::endpoints::OAuth2Keys) } - - /// Email verification URL - #[must_use] - pub fn email_verification(&self, code: String) -> Url { - self.url_for(&crate::endpoints::VerifyEmail(code)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_email_verification_url() { - let base = Url::parse("https://example.com/").unwrap(); - let builder = UrlBuilder::new(base); - assert_eq!( - builder.email_verification("123456abcdef".into()).as_str(), - "https://example.com/verify/123456abcdef" - ); - } } diff --git a/crates/storage/src/user.rs b/crates/storage/src/user.rs index 3ff7404c..9000e964 100644 --- a/crates/storage/src/user.rs +++ b/crates/storage/src/user.rs @@ -669,6 +669,36 @@ pub async fn lookup_user_email( Ok(res.into()) } +#[tracing::instrument(skip(executor))] +pub async fn lookup_user_email_by_id( + executor: impl PgExecutor<'_>, + user: &User, + id: i64, +) -> anyhow::Result> { + let res = sqlx::query_as!( + UserEmailLookup, + r#" + SELECT + ue.id AS "user_email_id", + ue.email AS "user_email", + ue.created_at AS "user_email_created_at", + ue.confirmed_at AS "user_email_confirmed_at" + FROM user_emails ue + + WHERE ue.user_id = $1 + AND ue.id = $2 + "#, + user.data, + id, + ) + .fetch_one(executor) + .instrument(info_span!("Lookup user email")) + .await + .context("could not lookup user email")?; + + Ok(res.into()) +} + #[tracing::instrument(skip(executor))] pub async fn mark_user_email_as_verified( executor: impl PgExecutor<'_>, @@ -695,18 +725,16 @@ pub async fn mark_user_email_as_verified( struct UserEmailVerificationLookup { verification_id: i64, + verification_code: String, verification_expired: bool, verification_created_at: DateTime, verification_consumed_at: Option>, - user_email_id: i64, - user_email: String, - user_email_created_at: DateTime, - user_email_confirmed_at: Option>, } #[tracing::instrument(skip(executor))] pub async fn lookup_user_email_verification_code( executor: impl PgExecutor<'_>, + email: UserEmail, code: &str, max_age: chrono::Duration, ) -> anyhow::Result> { @@ -720,19 +748,16 @@ pub async fn lookup_user_email_verification_code( r#" SELECT ev.id AS "verification_id", - (ev.created_at + $2 < NOW()) AS "verification_expired!", + ev.code AS "verification_code", + (ev.created_at + $3 < NOW()) AS "verification_expired!", ev.created_at AS "verification_created_at", - ev.consumed_at AS "verification_consumed_at", - ue.id AS "user_email_id", - ue.email AS "user_email", - ue.created_at AS "user_email_created_at", - ue.confirmed_at AS "user_email_confirmed_at" + ev.consumed_at AS "verification_consumed_at" FROM user_email_verifications ev - INNER JOIN user_emails ue - ON ue.id = ev.user_email_id WHERE ev.code = $1 + AND ev.user_email_id = $2 "#, code, + email.data, max_age, ) .fetch_one(executor) @@ -740,13 +765,6 @@ pub async fn lookup_user_email_verification_code( .await .context("could not lookup user email verification")?; - let email = UserEmail { - data: res.user_email_id, - email: res.user_email, - created_at: res.user_email_created_at, - confirmed_at: res.user_email_confirmed_at, - }; - let state = if res.verification_expired { UserEmailVerificationState::Expired } else if let Some(when) = res.verification_consumed_at { @@ -757,6 +775,7 @@ pub async fn lookup_user_email_verification_code( Ok(UserEmailVerification { data: res.verification_id, + code: res.verification_code, email, state, created_at: res.verification_created_at, @@ -794,21 +813,31 @@ pub async fn consume_email_verification( #[tracing::instrument(skip(executor, email), fields(email.id = email.data, %email.email))] pub async fn add_user_email_verification_code( executor: impl PgExecutor<'_>, - email: &UserEmail, - code: &str, -) -> anyhow::Result<()> { - sqlx::query!( + email: UserEmail, + code: String, +) -> anyhow::Result> { + let res = sqlx::query_as!( + IdAndCreationTime, r#" INSERT INTO user_email_verifications (user_email_id, code) VALUES ($1, $2) + RETURNING id, created_at "#, email.data, code, ) - .execute(executor) + .fetch_one(executor) .instrument(info_span!("Add user email verification code")) .await .context("could not insert user email verification code")?; - Ok(()) + let verification = UserEmailVerification { + data: res.id, + email, + code, + created_at: res.created_at, + state: UserEmailVerificationState::Valid, + }; + + Ok(verification) } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index c26a21f9..4c26971e 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -19,7 +19,7 @@ use chrono::Utc; use mas_data_model::{ AuthorizationGrant, BrowserSession, CompatSsoLogin, CompatSsoLoginState, StorageBackend, User, - UserEmail, + UserEmail, UserEmailVerification, }; use mas_router::PostAuthAction; use serde::{ser::SerializeStruct, Deserialize, Serialize}; @@ -562,21 +562,18 @@ impl TemplateContext for AccountEmailsContext { } } -/// Context used by the `emails/verification.{txt,html}` templates +/// Context used by the `emails/verification.{txt,html,subject}` templates #[derive(Serialize)] pub struct EmailVerificationContext { user: User<()>, - verification_link: Url, + verification: UserEmailVerification<()>, } impl EmailVerificationContext { /// Constructs a context for the verification email #[must_use] - pub fn new(user: User<()>, verification_link: Url) -> Self { - Self { - user, - verification_link, - } + pub fn new(user: User<()>, verification: UserEmailVerification<()>) -> Self { + Self { user, verification } } } @@ -587,16 +584,84 @@ impl TemplateContext for EmailVerificationContext { { User::samples() .into_iter() - .map(|u| { - Self::new( - u, - Url::parse("https://example.com/emails/verify?code=2134").unwrap(), - ) + .map(|user| { + let email = UserEmail { + data: (), + email: "foobar@example.com".to_string(), + created_at: Utc::now(), + confirmed_at: None, + }; + + let verification = UserEmailVerification { + data: (), + code: "123456".to_string(), + email, + created_at: Utc::now(), + state: mas_data_model::UserEmailVerificationState::Valid, + }; + + Self { user, verification } }) .collect() } } +/// Fields of the email verification form +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EmailVerificationFormField { + /// The code field + Code, +} + +impl FormField for EmailVerificationFormField { + fn keep(&self) -> bool { + match self { + Self::Code => true, + } + } +} + +/// Context used by the `pages/account/verify.html` templates +#[derive(Serialize)] +pub struct EmailVerificationPageContext { + form: FormState, + email: UserEmail<()>, +} + +impl EmailVerificationPageContext { + /// Constructs a context for the email verification page + #[must_use] + pub fn new(email: T) -> Self + where + T: Into>, + { + Self { + form: FormState::default(), + email: email.into(), + } + } +} + +impl TemplateContext for EmailVerificationPageContext { + fn sample() -> Vec + where + Self: Sized, + { + let email = UserEmail { + data: (), + email: "foobar@example.com".to_string(), + created_at: Utc::now(), + confirmed_at: None, + }; + + vec![Self { + form: FormState::default(), + email, + }] + } +} + /// Context used by the `form_post.html` template #[derive(Serialize)] pub struct FormPostContext { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index b0330080..d4a39827 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -46,10 +46,10 @@ mod macros; pub use self::{ context::{ AccountContext, AccountEmailsContext, CompatSsoContext, ConsentContext, - EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, - LoginContext, LoginFormField, PostAuthContext, ReauthContext, ReauthFormField, - RegisterContext, RegisterFormField, TemplateContext, WithCsrf, WithOptionalSession, - WithSession, + EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext, + FormPostContext, IndexContext, LoginContext, LoginFormField, PostAuthContext, + ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, TemplateContext, + WithCsrf, WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -154,7 +154,7 @@ impl Templates { // This uses blocking I/Os, do that in a blocking task let tera = tokio::task::spawn_blocking(move || { // Using `to_string_lossy` here is probably fine - let path = format!("{}/**/*.{{html,txt}}", root.to_string_lossy()); + let path = format!("{}/**/*.{{html,txt,subject}}", root.to_string_lossy()); info!(%path, "Loading templates from filesystem"); Tera::parse(&path) }) @@ -326,11 +326,14 @@ register_templates! { /// Render the email verification email (plain text variant) pub fn render_email_verification_txt(EmailVerificationContext) { "emails/verification.txt" } - /// Render the email verification email (plain text variant) + /// Render the email verification email (HTML text variant) pub fn render_email_verification_html(EmailVerificationContext) { "emails/verification.html" } + /// Render the email verification subject + pub fn render_email_verification_subject(EmailVerificationContext) { "emails/verification.subject" } + /// Render the email post-email verification page - pub fn render_email_verification_done(WithCsrf>) { "pages/verify.html" } + pub fn render_email_verification_form(WithCsrf>) { "pages/account/verify.html" } } impl Templates { @@ -340,6 +343,7 @@ impl Templates { check::render_login(self).await?; check::render_register(self).await?; check::render_consent(self).await?; + check::render_sso_login(self).await?; check::render_index(self).await?; check::render_account_index(self).await?; check::render_account_password(self).await?; @@ -349,7 +353,8 @@ impl Templates { check::render_error(self).await?; check::render_email_verification_txt(self).await?; check::render_email_verification_html(self).await?; - check::render_email_verification_done(self).await?; + check::render_email_verification_subject(self).await?; + check::render_email_verification_form(self).await?; Ok(()) } } diff --git a/crates/templates/src/res/components/field.html b/crates/templates/src/res/components/field.html index a25463a8..bdbf6a03 100644 --- a/crates/templates/src/res/components/field.html +++ b/crates/templates/src/res/components/field.html @@ -5,7 +5,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 +http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -14,44 +14,44 @@ See the License for the specific language governing permissions and limitations under the License. #} -{% macro input(label, name, type="text", form_state=false, autocomplete=false, class="") %} - {% if not form_state %} - {% set form_state = dict(errors=[], fields=dict()) %} - {% endif %} +{% macro input(label, name, type="text", form_state=false, autocomplete=false, class="", inputmode="text") %} +{% if not form_state %} +{% set form_state = dict(errors=[], fields=dict()) %} +{% endif %} - {% set state = form_state.fields[name] | default(value=dict(errors=[], value="")) %} +{% set state = form_state.fields[name] | default(value=dict(errors=[], value="")) %} + +{% if state.errors is not empty %} +{% set border_color = "border-alert" %} +{% set text_color = "text-alert" %} +{% else %} +{% set border_color = "border-grey-50 dark:border-grey-450" %} +{% set text_color = "text-black-800 dark:text-grey-300" %} +{% endif %} + +