diff --git a/crates/data-model/src/traits.rs b/crates/data-model/src/traits.rs index db13138c..f3efa83a 100644 --- a/crates/data-model/src/traits.rs +++ b/crates/data-model/src/traits.rs @@ -12,18 +12,22 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::fmt::Debug; + +use serde::{de::DeserializeOwned, Serialize}; + pub trait StorageBackendMarker: StorageBackend {} pub trait StorageBackend { - type UserData: Clone + std::fmt::Debug + PartialEq; - type UserEmailData: Clone + std::fmt::Debug + PartialEq; - type AuthenticationData: Clone + std::fmt::Debug + PartialEq; - type BrowserSessionData: Clone + std::fmt::Debug + PartialEq; - type ClientData: Clone + std::fmt::Debug + PartialEq; - type SessionData: Clone + std::fmt::Debug + PartialEq; - type AuthorizationGrantData: Clone + std::fmt::Debug + PartialEq; - type AccessTokenData: Clone + std::fmt::Debug + PartialEq; - type RefreshTokenData: Clone + std::fmt::Debug + PartialEq; + type UserData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type UserEmailData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type AuthenticationData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type BrowserSessionData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type ClientData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type SessionData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type AuthorizationGrantData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type AccessTokenData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type RefreshTokenData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; } impl StorageBackend for () { diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 6642c9c6..4fd1ad5b 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -20,7 +20,6 @@ use crate::traits::{StorageBackend, StorageBackendMarker}; #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(bound = "T: StorageBackend")] pub struct User { - #[serde(skip_serializing)] pub data: T::UserData, pub username: String, pub sub: String, @@ -73,7 +72,6 @@ impl From> for Authentication<()> { #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(bound = "T: StorageBackend")] pub struct BrowserSession { - #[serde(skip_serializing)] pub data: T::BrowserSessionData, pub user: User, pub created_at: DateTime, @@ -113,7 +111,6 @@ where #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(bound = "T: StorageBackend")] pub struct UserEmail { - #[serde(skip_serializing)] pub data: T::UserEmailData, pub email: String, pub created_at: DateTime, diff --git a/crates/handlers/src/oauth2/authorization.rs b/crates/handlers/src/oauth2/authorization.rs index 52b10801..f9dab624 100644 --- a/crates/handlers/src/oauth2/authorization.rs +++ b/crates/handlers/src/oauth2/authorization.rs @@ -406,7 +406,7 @@ async fn get( .await .wrap_error()?; - let next = ContinueAuthorizationGrant::from_authorization_grant(grant); + let next = ContinueAuthorizationGrant::from_authorization_grant(&grant); match (maybe_session, params.auth.prompt) { (None, Some(Prompt::None)) => { @@ -419,8 +419,8 @@ async fn get( // TODO: better pages here txn.commit().await.wrap_error()?; - let next: PostAuthAction<_> = next.into(); - let next: ReauthRequest<_> = next.into(); + let next: PostAuthAction = next.into(); + let next: ReauthRequest = next.into(); let next = next.build_uri().wrap_error()?; Ok(ReplyOrBackToClient::Reply(Box::new(see_other(next)))) @@ -433,8 +433,8 @@ async fn get( // Other cases where we don't have a session, ask for a login txn.commit().await.wrap_error()?; - let next: PostAuthAction<_> = next.into(); - let next: LoginRequest<_> = next.into(); + let next: PostAuthAction = next.into(); + let next: LoginRequest = next.into(); let next = next.build_uri().wrap_error()?; Ok(ReplyOrBackToClient::Reply(Box::new(see_other(next)))) @@ -443,27 +443,21 @@ async fn get( } #[derive(Serialize, Deserialize, Clone)] -pub(crate) struct ContinueAuthorizationGrant { - #[serde( - with = "serde_with::rust::display_fromstr", - bound( - deserialize = "S::AuthorizationGrantData: std::str::FromStr, - ::Err: std::fmt::Display", - serialize = "S::AuthorizationGrantData: std::fmt::Display" - ) - )] - data: S::AuthorizationGrantData, +pub(crate) struct ContinueAuthorizationGrant { + data: String, } -impl ContinueAuthorizationGrant { - pub fn from_authorization_grant(grant: AuthorizationGrant) -> Self { - Self { data: grant.data } - } - - pub fn build_uri(&self) -> anyhow::Result +impl ContinueAuthorizationGrant { + pub fn from_authorization_grant(grant: &AuthorizationGrant) -> Self where S::AuthorizationGrantData: std::fmt::Display, { + Self { + data: grant.data.to_string(), + } + } + + pub fn build_uri(&self) -> anyhow::Result { let qs = serde_urlencoded::to_string(self)?; let path_and_query = PathAndQuery::try_from(format!("/oauth2/authorize/step?{}", qs))?; let uri = Uri::from_parts({ @@ -473,19 +467,18 @@ impl ContinueAuthorizationGrant { })?; Ok(uri) } -} -impl ContinueAuthorizationGrant { pub async fn fetch_authorization_grant( &self, executor: impl PgExecutor<'_>, ) -> anyhow::Result> { - get_grant_by_id(executor, self.data).await + let data = self.data.parse()?; + get_grant_by_id(executor, data).await } } async fn step( - next: ContinueAuthorizationGrant, + next: ContinueAuthorizationGrant, browser_session: BrowserSession, mut txn: Transaction<'_, Postgres>, ) -> Result { @@ -559,8 +552,8 @@ async fn step( } } _ => { - let next: PostAuthAction<_> = next.into(); - let next: ReauthRequest<_> = next.into(); + let next: PostAuthAction = next.into(); + let next: ReauthRequest = next.into(); let next = next.build_uri().wrap_error()?; ReplyOrBackToClient::Reply(Box::new(see_other(next))) diff --git a/crates/handlers/src/views/account/emails.rs b/crates/handlers/src/views/account/emails.rs new file mode 100644 index 00000000..a34ce4c4 --- /dev/null +++ b/crates/handlers/src/views/account/emails.rs @@ -0,0 +1,156 @@ +// 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 mas_config::{CookiesConfig, CsrfConfig}; +use mas_data_model::BrowserSession; +use mas_storage::{ + user::{ + add_user_email, get_user_email, get_user_emails, remove_user_email, + set_user_email_as_primary, + }, + PostgresqlBackend, +}; +use mas_templates::{AccountEmailsContext, TemplateContext, Templates}; +use mas_warp_utils::{ + errors::WrapError, + filters::{ + cookies::{encrypted_cookie_saver, EncryptedCookieSaver}, + csrf::{protected_form, updated_csrf_token}, + database::{connection, transaction}, + session::session, + with_templates, CsrfToken, + }, +}; +use serde::Deserialize; +use sqlx::{pool::PoolConnection, PgExecutor, PgPool, Postgres, Transaction}; +use tracing::info; +use warp::{filters::BoxedFilter, reply::html, Filter, Rejection, Reply}; + +pub(super) fn filter( + pool: &PgPool, + templates: &Templates, + csrf_config: &CsrfConfig, + cookies_config: &CookiesConfig, +) -> BoxedFilter<(Box,)> { + let get = with_templates(templates) + .and(encrypted_cookie_saver(cookies_config)) + .and(updated_csrf_token(cookies_config, csrf_config)) + .and(session(pool, cookies_config)) + .and(connection(pool)) + .and_then(get); + + let post = with_templates(templates) + .and(encrypted_cookie_saver(cookies_config)) + .and(updated_csrf_token(cookies_config, csrf_config)) + .and(session(pool, cookies_config)) + .and(transaction(pool)) + .and(protected_form(cookies_config)) + .and_then(post); + + let get = warp::get().and(get); + let post = warp::post().and(post); + let filter = get.or(post).unify(); + + warp::path!("emails").and(filter).boxed() +} + +#[derive(Deserialize, Debug)] +#[serde(tag = "action", rename_all = "snake_case")] +enum Form { + Add { email: String }, + ResendConfirmation { data: String }, + SetPrimary { data: String }, + Remove { data: String }, +} + +async fn get( + templates: Templates, + cookie_saver: EncryptedCookieSaver, + csrf_token: CsrfToken, + session: BrowserSession, + mut conn: PoolConnection, +) -> Result, Rejection> { + render(templates, cookie_saver, csrf_token, session, &mut conn).await +} + +async fn render( + templates: Templates, + cookie_saver: EncryptedCookieSaver, + csrf_token: CsrfToken, + session: BrowserSession, + executor: impl PgExecutor<'_>, +) -> Result, Rejection> { + let emails = get_user_emails(executor, &session.user) + .await + .wrap_error()?; + + let ctx = AccountEmailsContext::new(emails) + .with_session(session) + .with_csrf(csrf_token.form_value()); + + let content = templates.render_account_emails(&ctx).await?; + let reply = html(content); + let reply = cookie_saver.save_encrypted(&csrf_token, reply)?; + + Ok(Box::new(reply)) +} + +async fn post( + templates: Templates, + cookie_saver: EncryptedCookieSaver, + csrf_token: CsrfToken, + mut session: BrowserSession, + mut txn: Transaction<'_, Postgres>, + form: Form, +) -> Result, Rejection> { + match form { + Form::Add { email } => { + // TODO: verify email format + // TODO: send verification email + add_user_email(&mut txn, &session.user, email) + .await + .wrap_error()?; + } + Form::Remove { data } => { + let id = data.parse().wrap_error()?; + let email = get_user_email(&mut txn, &session.user, id) + .await + .wrap_error()?; + remove_user_email(&mut txn, email).await.wrap_error()?; + } + Form::ResendConfirmation { data } => { + let id: i64 = data.parse().wrap_error()?; + info!( + email.id = id, + "Not implemented yet: re-send confirmation email" + ); + } + Form::SetPrimary { data } => { + let id = data.parse().wrap_error()?; + let email = get_user_email(&mut txn, &session.user, id) + .await + .wrap_error()?; + set_user_email_as_primary(&mut txn, &email) + .await + .wrap_error()?; + session.user.primary_email = Some(email); + } + }; + + let reply = render(templates, cookie_saver, csrf_token, session, &mut txn).await?; + + txn.commit().await.wrap_error()?; + + Ok(reply) +} diff --git a/crates/handlers/src/views/account/mod.rs b/crates/handlers/src/views/account/mod.rs index dd546fe6..a179caf2 100644 --- a/crates/handlers/src/views/account/mod.rs +++ b/crates/handlers/src/views/account/mod.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +mod emails; mod password; use mas_config::{CookiesConfig, CsrfConfig}; @@ -34,7 +35,7 @@ use mas_warp_utils::{ use sqlx::{pool::PoolConnection, PgPool, Postgres}; use warp::{filters::BoxedFilter, reply::html, Filter, Rejection, Reply}; -use self::password::filter as password; +use self::{emails::filter as emails, password::filter as password}; pub(super) fn filter( pool: &PgPool, @@ -52,8 +53,9 @@ pub(super) fn filter( let index = warp::path::end().and(get); let password = password(pool, templates, csrf_config, cookies_config); + let emails = emails(pool, templates, csrf_config, cookies_config); - let filter = index.or(password).unify(); + let filter = index.or(password).unify().or(emails).unify(); warp::path::path("account").and(filter).boxed() } diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index eaeda5d4..c2bc46f9 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -12,9 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +#![allow(clippy::trait_duplication_in_bounds)] + use hyper::http::uri::{Parts, PathAndQuery, Uri}; use mas_config::{CookiesConfig, CsrfConfig}; -use mas_data_model::{errors::WrapFormError, BrowserSession, StorageBackend}; +use mas_data_model::{errors::WrapFormError, BrowserSession}; use mas_storage::{user::login, PostgresqlBackend}; use mas_templates::{LoginContext, LoginFormField, TemplateContext, Templates}; use mas_warp_utils::{ @@ -34,26 +36,21 @@ use warp::{filters::BoxedFilter, reply::html, Filter, Rejection, Reply}; use super::{shared::PostAuthAction, RegisterRequest}; #[derive(Deserialize)] -#[serde(bound(deserialize = "S::AuthorizationGrantData: std::str::FromStr, - ::Err: std::fmt::Display"))] -pub(crate) struct LoginRequest { +pub(crate) struct LoginRequest { #[serde(flatten)] - post_auth_action: Option>, + post_auth_action: Option, } -impl From> for LoginRequest { - fn from(post_auth_action: PostAuthAction) -> Self { +impl From for LoginRequest { + fn from(post_auth_action: PostAuthAction) -> Self { Self { post_auth_action: Some(post_auth_action), } } } -impl LoginRequest { - pub fn build_uri(&self) -> anyhow::Result - where - S::AuthorizationGrantData: std::fmt::Display, - { +impl LoginRequest { + pub fn build_uri(&self) -> anyhow::Result { let path_and_query = if let Some(next) = &self.post_auth_action { let qs = serde_urlencoded::to_string(next)?; PathAndQuery::try_from(format!("/login?{}", qs))? @@ -68,10 +65,7 @@ impl LoginRequest { Ok(uri) } - fn redirect(self) -> Result - where - S::AuthorizationGrantData: std::fmt::Display, - { + fn redirect(self) -> Result { let uri = self .post_auth_action .as_ref() @@ -121,7 +115,7 @@ async fn get( mut conn: PoolConnection, cookie_saver: EncryptedCookieSaver, csrf_token: CsrfToken, - query: LoginRequest, + query: LoginRequest, maybe_session: Option>, ) -> Result, Rejection> { if maybe_session.is_some() { @@ -153,7 +147,7 @@ async fn post( cookie_saver: EncryptedCookieSaver, csrf_token: CsrfToken, form: LoginForm, - query: LoginRequest, + query: LoginRequest, ) -> Result, Rejection> { use mas_storage::user::LoginError; // TODO: recover @@ -187,7 +181,7 @@ mod tests { #[test] fn deserialize_login_request() { - let res: Result, _> = + let res: Result = serde_urlencoded::from_str("next=continue_authorization_grant&data=13"); res.unwrap().post_auth_action.unwrap(); } diff --git a/crates/handlers/src/views/reauth.rs b/crates/handlers/src/views/reauth.rs index 37132ee9..ee56c03a 100644 --- a/crates/handlers/src/views/reauth.rs +++ b/crates/handlers/src/views/reauth.rs @@ -14,7 +14,7 @@ use hyper::http::uri::{Parts, PathAndQuery}; use mas_config::{CookiesConfig, CsrfConfig}; -use mas_data_model::{BrowserSession, StorageBackend}; +use mas_data_model::BrowserSession; use mas_storage::{user::authenticate_session, PostgresqlBackend}; use mas_templates::{ReauthContext, TemplateContext, Templates}; use mas_warp_utils::{ @@ -34,26 +34,21 @@ use warp::{filters::BoxedFilter, hyper::Uri, reply::html, Filter, Rejection, Rep use super::PostAuthAction; #[derive(Deserialize)] -#[serde(bound(deserialize = "S::AuthorizationGrantData: std::str::FromStr, - ::Err: std::fmt::Display"))] -pub(crate) struct ReauthRequest { +pub(crate) struct ReauthRequest { #[serde(flatten)] - post_auth_action: Option>, + post_auth_action: Option, } -impl From> for ReauthRequest { - fn from(post_auth_action: PostAuthAction) -> Self { +impl From for ReauthRequest { + fn from(post_auth_action: PostAuthAction) -> Self { Self { post_auth_action: Some(post_auth_action), } } } -impl ReauthRequest { - pub fn build_uri(&self) -> anyhow::Result - where - S::AuthorizationGrantData: std::fmt::Display, - { +impl ReauthRequest { + pub fn build_uri(&self) -> anyhow::Result { let path_and_query = if let Some(next) = &self.post_auth_action { let qs = serde_urlencoded::to_string(next)?; PathAndQuery::try_from(format!("/reauth?{}", qs))? @@ -68,10 +63,7 @@ impl ReauthRequest { Ok(uri) } - fn redirect(self) -> Result - where - S::AuthorizationGrantData: std::fmt::Display, - { + fn redirect(self) -> Result { let uri = self .post_auth_action .as_ref() @@ -119,7 +111,7 @@ async fn get( cookie_saver: EncryptedCookieSaver, csrf_token: CsrfToken, session: BrowserSession, - query: ReauthRequest, + query: ReauthRequest, ) -> Result, Rejection> { let ctx = ReauthContext::default(); let ctx = match query.post_auth_action { @@ -141,7 +133,7 @@ async fn post( mut session: BrowserSession, mut txn: Transaction<'_, Postgres>, form: ReauthForm, - query: ReauthRequest, + query: ReauthRequest, ) -> Result, Rejection> { // TODO: recover from errors here authenticate_session(&mut txn, &mut session, form.password) diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index 76146f07..2ce37c5f 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -12,10 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +#![allow(clippy::trait_duplication_in_bounds)] + use argon2::Argon2; use hyper::http::uri::{Parts, PathAndQuery, Uri}; use mas_config::{CookiesConfig, CsrfConfig}; -use mas_data_model::{BrowserSession, StorageBackend}; +use mas_data_model::BrowserSession; use mas_storage::{ user::{register_user, start_session}, PostgresqlBackend, @@ -38,27 +40,22 @@ use warp::{filters::BoxedFilter, reply::html, Filter, Rejection, Reply}; use super::{LoginRequest, PostAuthAction}; #[derive(Deserialize)] -#[serde(bound(deserialize = "S::AuthorizationGrantData: std::str::FromStr, - ::Err: std::fmt::Display"))] -pub struct RegisterRequest { +pub struct RegisterRequest { #[serde(flatten)] - post_auth_action: Option>, + post_auth_action: Option, } -impl From> for RegisterRequest { - fn from(post_auth_action: PostAuthAction) -> Self { +impl From for RegisterRequest { + fn from(post_auth_action: PostAuthAction) -> Self { Self { post_auth_action: Some(post_auth_action), } } } -impl RegisterRequest { +impl RegisterRequest { #[allow(dead_code)] - pub fn build_uri(&self) -> anyhow::Result - where - S::AuthorizationGrantData: std::fmt::Display, - { + pub fn build_uri(&self) -> anyhow::Result { let path_and_query = if let Some(next) = &self.post_auth_action { let qs = serde_urlencoded::to_string(next)?; PathAndQuery::try_from(format!("/register?{}", qs))? @@ -73,10 +70,7 @@ impl RegisterRequest { Ok(uri) } - fn redirect(self) -> Result - where - S::AuthorizationGrantData: std::fmt::Display, - { + fn redirect(self) -> Result { let uri = self .post_auth_action .as_ref() @@ -125,7 +119,7 @@ async fn get( mut conn: PoolConnection, cookie_saver: EncryptedCookieSaver, csrf_token: CsrfToken, - query: RegisterRequest, + query: RegisterRequest, maybe_session: Option>, ) -> Result, Rejection> { if maybe_session.is_some() { @@ -153,7 +147,7 @@ async fn post( mut txn: Transaction<'_, Postgres>, cookie_saver: EncryptedCookieSaver, form: RegisterForm, - query: RegisterRequest, + query: RegisterRequest, ) -> Result, Rejection> { // TODO: display nice form errors if form.password != form.password_confirm { diff --git a/crates/handlers/src/views/shared.rs b/crates/handlers/src/views/shared.rs index 78384a90..002ae52c 100644 --- a/crates/handlers/src/views/shared.rs +++ b/crates/handlers/src/views/shared.rs @@ -12,9 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +#![allow(clippy::trait_duplication_in_bounds)] + use hyper::Uri; -use mas_data_model::StorageBackend; -use mas_storage::PostgresqlBackend; use mas_templates::PostAuthContext; use serde::{Deserialize, Serialize}; use sqlx::PgExecutor; @@ -23,33 +23,17 @@ use super::super::oauth2::ContinueAuthorizationGrant; #[derive(Deserialize, Serialize, Clone)] #[serde(rename_all = "snake_case", tag = "next")] -pub(crate) enum PostAuthAction { - #[serde(bound( - deserialize = "S::AuthorizationGrantData: std::str::FromStr, - ::Err: std::fmt::Display", - serialize = "S::AuthorizationGrantData: std::fmt::Display" - ))] - ContinueAuthorizationGrant(ContinueAuthorizationGrant), +pub(crate) enum PostAuthAction { + ContinueAuthorizationGrant(ContinueAuthorizationGrant), } -impl PostAuthAction { - pub fn build_uri(&self) -> anyhow::Result - where - S::AuthorizationGrantData: std::fmt::Display, - { +impl PostAuthAction { + pub fn build_uri(&self) -> anyhow::Result { match self { PostAuthAction::ContinueAuthorizationGrant(c) => c.build_uri(), } } -} -impl From> for PostAuthAction { - fn from(g: ContinueAuthorizationGrant) -> Self { - Self::ContinueAuthorizationGrant(g) - } -} - -impl PostAuthAction { pub async fn load_context<'e>( &self, executor: impl PgExecutor<'e>, @@ -63,3 +47,9 @@ impl PostAuthAction { } } } + +impl From for PostAuthAction { + fn from(g: ContinueAuthorizationGrant) -> Self { + Self::ContinueAuthorizationGrant(g) + } +} diff --git a/crates/storage/sqlx-data.json b/crates/storage/sqlx-data.json index 2047cc86..d1747e68 100644 --- a/crates/storage/sqlx-data.json +++ b/crates/storage/sqlx-data.json @@ -533,6 +533,18 @@ ] } }, + "4b9de6face2e21117c947b4f550cc747ad8397b6dfadb6bc6a84124763dc66e8": { + "query": "\n UPDATE users\n SET primary_email_id = user_emails.id \n FROM user_emails\n WHERE user_emails.id = $1\n AND users.id = user_emails.user_id\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, "581243a7f0c033548cc9644e0c60855ecb8bfefe51779eb135dd7547b886de79": { "query": "\n UPDATE oauth2_sessions\n SET ended_at = NOW()\n WHERE id = $1\n ", "describe": { @@ -603,6 +615,45 @@ ] } }, + "6da88febe6d8e45787cdd609dcea5f51dc601f4dffb07dd4c5d699c7d4c5b2d1": { + "query": "\n INSERT INTO user_emails (user_id, email)\n VALUES ($1, $2)\n RETURNING \n id AS user_email_id,\n email AS user_email,\n created_at AS user_email_created_at,\n confirmed_at AS user_email_confirmed_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_email_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "user_email_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "user_email_confirmed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + } + }, "703850ba4e001d53776d77a64cbc1ee6feb61485ce41aff1103251f9b3778128": { "query": "\n UPDATE oauth2_authorization_grants AS og\n SET\n oauth2_session_id = os.id,\n fulfilled_at = os.created_at\n FROM oauth2_sessions os\n WHERE\n og.id = $1 AND os.id = $2\n RETURNING fulfilled_at AS \"fulfilled_at!: DateTime\"\n ", "describe": { @@ -698,6 +749,45 @@ ] } }, + "b0fec01072df856ba9cd8be0ecf7a58dd4709a0efca4035a2c6f99c43d5a12be": { + "query": "\n SELECT \n ue.id AS \"user_email_id\",\n ue.email AS \"user_email\",\n ue.created_at AS \"user_email_created_at\",\n ue.confirmed_at AS \"user_email_confirmed_at\"\n FROM user_emails ue\n\n WHERE ue.user_id = $1\n AND ue.id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_email_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "user_email_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "user_email_confirmed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + } + }, "c29e741474aacc91c0aacc028a9e7452a5327d5ce6d4b791bf20a2636069087e": { "query": "\n INSERT INTO oauth2_sessions\n (user_session_id, client_id, scope)\n SELECT\n $1,\n og.client_id,\n og.scope\n FROM\n oauth2_authorization_grants og\n WHERE\n og.id = $2\n RETURNING id, created_at\n ", "describe": { @@ -854,6 +944,18 @@ ] } }, + "d2f767218ec2489058db9a0382ca0eea20379c30aeae9f492da4ba35b66f4dc7": { + "query": "\n DELETE FROM user_emails\n WHERE user_emails.id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, "d3883020ad9a0e5ea72fb9ddd2801a067209488a6ef3179afbc8173e4cc729de": { "query": "\n SELECT\n og.id AS grant_id,\n og.created_at AS grant_created_at,\n og.cancelled_at AS grant_cancelled_at,\n og.fulfilled_at AS grant_fulfilled_at,\n og.exchanged_at AS grant_exchanged_at,\n og.scope AS grant_scope,\n og.state AS grant_state,\n og.redirect_uri AS grant_redirect_uri,\n og.response_mode AS grant_response_mode,\n og.nonce AS grant_nonce,\n og.max_age AS grant_max_age,\n og.acr_values AS grant_acr_values,\n og.client_id AS client_id,\n og.code AS grant_code,\n og.response_type_code AS grant_response_type_code,\n og.response_type_token AS grant_response_type_token,\n og.response_type_id_token AS grant_response_type_id_token,\n og.code_challenge AS grant_code_challenge,\n og.code_challenge_method AS grant_code_challenge_method,\n os.id AS \"session_id?\",\n us.id AS \"user_session_id?\",\n us.created_at AS \"user_session_created_at?\",\n u.id AS \"user_id?\",\n u.username AS \"user_username?\",\n usa.id AS \"user_session_last_authentication_id?\",\n usa.created_at AS \"user_session_last_authentication_created_at?\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n FROM\n oauth2_authorization_grants og\n LEFT JOIN oauth2_sessions os\n ON os.id = og.oauth2_session_id\n LEFT JOIN user_sessions us\n ON us.id = os.user_session_id\n LEFT JOIN users u\n ON u.id = us.user_id\n LEFT JOIN user_session_authentications usa\n ON usa.session_id = us.id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE og.code = $1\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n ", "describe": { diff --git a/crates/storage/src/user.rs b/crates/storage/src/user.rs index 63ab45c0..752458ba 100644 --- a/crates/storage/src/user.rs +++ b/crates/storage/src/user.rs @@ -531,3 +531,103 @@ pub async fn get_user_emails( Ok(res.into_iter().map(Into::into).collect()) } + +#[tracing::instrument(skip_all, fields(user.id = user.data, %user.username, email.id = id))] +pub async fn get_user_email( + executor: impl PgExecutor<'_>, + user: &User, + id: i64, +) -> Result, anyhow::Error> { + 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!("Fetch user emails")) + .await?; + + Ok(res.into()) +} + +#[tracing::instrument(skip(executor, user), fields(user.id = user.data, %user.username))] +pub async fn add_user_email( + executor: impl PgExecutor<'_>, + user: &User, + email: String, +) -> anyhow::Result> { + let res = sqlx::query_as!( + UserEmailLookup, + r#" + INSERT INTO user_emails (user_id, email) + VALUES ($1, $2) + RETURNING + id AS user_email_id, + email AS user_email, + created_at AS user_email_created_at, + confirmed_at AS user_email_confirmed_at + "#, + user.data, + email, + ) + .fetch_one(executor) + .instrument(info_span!("Add user email")) + .await + .context("could not insert user email")?; + + Ok(res.into()) +} + +#[tracing::instrument(skip(executor))] +pub async fn set_user_email_as_primary( + executor: impl PgExecutor<'_>, + email: &UserEmail, +) -> anyhow::Result<()> { + sqlx::query!( + r#" + UPDATE users + SET primary_email_id = user_emails.id + FROM user_emails + WHERE user_emails.id = $1 + AND users.id = user_emails.user_id + "#, + email.data, + ) + .execute(executor) + .instrument(info_span!("Add user email")) + .await + .context("could not set user email as primary")?; + + Ok(()) +} + +#[tracing::instrument(skip(executor))] +pub async fn remove_user_email( + executor: impl PgExecutor<'_>, + email: UserEmail, +) -> anyhow::Result<()> { + sqlx::query!( + r#" + DELETE FROM user_emails + WHERE user_emails.id = $1 + "#, + email.data, + ) + .execute(executor) + .instrument(info_span!("Remove user email")) + .await + .context("could not remove user email")?; + + Ok(()) +} diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index ca9c29a4..0bb6b906 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -14,6 +14,8 @@ //! Contexts used in templates +#![allow(clippy::trait_duplication_in_bounds)] + use mas_data_model::{ errors::ErroredForm, AuthorizationGrant, BrowserSession, StorageBackend, UserEmail, }; @@ -384,7 +386,7 @@ pub struct ReauthContext { next: Option, } -/// Context used by the `account.html` template +/// Context used by the `account/index.html` template #[derive(Serialize)] pub struct AccountContext { active_sessions: usize, @@ -414,6 +416,30 @@ impl TemplateContext for AccountContext { } } +/// Context used by the `account/emails.html` template +#[derive(Serialize)] +#[serde(bound(serialize = "T: StorageBackend"))] +pub struct AccountEmailsContext { + emails: Vec>, +} + +impl AccountEmailsContext { + #[must_use] + pub fn new(emails: Vec>) -> Self { + Self { emails } + } +} + +impl TemplateContext for AccountEmailsContext { + fn sample() -> Vec + where + Self: Sized, + { + let emails: Vec> = UserEmail::samples(); + vec![Self::new(emails)] + } +} + /// 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 b4a13706..10ce57cc 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -33,6 +33,7 @@ use std::{ use anyhow::{bail, Context as _}; use mas_config::TemplatesConfig; +use mas_data_model::StorageBackend; use serde::Serialize; use tera::{Context, Error as TeraError, Tera}; use thiserror::Error; @@ -47,9 +48,10 @@ mod functions; mod macros; pub use self::context::{ - AccountContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, - LoginFormField, PostAuthContext, ReauthContext, ReauthFormField, RegisterContext, - RegisterFormField, TemplateContext, WithCsrf, WithOptionalSession, WithSession, + AccountContext, AccountEmailsContext, EmptyContext, ErrorContext, FormPostContext, + IndexContext, LoginContext, LoginFormField, PostAuthContext, ReauthContext, ReauthFormField, + RegisterContext, RegisterFormField, TemplateContext, WithCsrf, WithOptionalSession, + WithSession, }; /// Wrapper around [`tera::Tera`] helping rendering the various templates @@ -299,6 +301,9 @@ register_templates! { /// Render the password change page pub fn render_account_password(WithCsrf>) { "pages/account/password.html" } + /// Render the emails management + pub fn render_account_emails(WithCsrf>>) { "pages/account/emails.html" } + /// Render the re-authentication form pub fn render_reauth(WithCsrf>) { "pages/reauth.html" } diff --git a/crates/templates/src/res/components/button.html b/crates/templates/src/res/components/button.html index acedec66..2fe71a32 100644 --- a/crates/templates/src/res/components/button.html +++ b/crates/templates/src/res/components/button.html @@ -42,14 +42,14 @@ limitations under the License. {{ text }} {% endmacro %} -{% macro button(text, name="", type="submit", class="") %} - +{% macro button(text, name="", type="submit", class="", value="") %} + {% endmacro %} -{% macro button_text(text, name="", type="submit", class="") %} - +{% macro button_text(text, name="", type="submit", class="", value="") %} + {% endmacro %} -{% macro button_ghost(text, name="", type="submit", class="") %} - +{% macro button_ghost(text, name="", type="submit", class="", value="") %} + {% endmacro %} diff --git a/crates/templates/src/res/pages/account/emails.html b/crates/templates/src/res/pages/account/emails.html new file mode 100644 index 00000000..3d8f15bb --- /dev/null +++ b/crates/templates/src/res/pages/account/emails.html @@ -0,0 +1,59 @@ +{# +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. +#} + +{% extends "base.html" %} + +{% block content %} + {% if current_session.user.primary_email %} + {% set primary_email = current_session.user.primary_email.email %} + {% else %} + {% set primary_email = "" %} + {% endif %} + +
+
+

Add email

+ + {{ field::input(label="New email", name="email", type="email", class="xl:col-span-2") }} + {{ button::button(text="Add email", type="submit", class="xl:col-span-2 place-self-end", name="action", value="add") }} +
+ +
+

Emails

+ {% for item in emails %} +
+ + +
{{ item.email }}
+ {% if item.confirmed_at %} +
Verified
+ {% else %} + {{ button::button(text="Resend verification", type="submit", name="action", value="resend_confirmation", class="mr-4") }} + {% endif %} + + {% if item.email == primary_email %} +
Primary
+ {% else %} + {{ button::button(text="Set as primary", type="submit", name="action", value="set_primary", class="mr-4") }} + {% endif %} + {{ button::button(text="Delete", type="submit", name="action", value="remove") }} +
+ {% endfor %} +
+
+{% endblock content %} + + diff --git a/crates/templates/src/res/pages/account/index.html b/crates/templates/src/res/pages/account/index.html index fe5ce129..5fd3cafa 100644 --- a/crates/templates/src/res/pages/account/index.html +++ b/crates/templates/src/res/pages/account/index.html @@ -52,6 +52,7 @@ limitations under the License.
{{ email.email }}
{% if email.confirmed_at %}Confirmed{% else %}Unconfirmed{% endif %}
{% endfor %} + {{ button::link_ghost(text="Manage", href="/account/emails", class="col-span-2 place-self-end") }} {% endblock content %} diff --git a/crates/templates/src/res/pages/account/password.html b/crates/templates/src/res/pages/account/password.html index ce44e928..28723a23 100644 --- a/crates/templates/src/res/pages/account/password.html +++ b/crates/templates/src/res/pages/account/password.html @@ -24,7 +24,7 @@ limitations under the License. {{ field::input(label="Current password", name="current_password", type="password", class="xl:col-span-2") }} {{ field::input(label="New password", name="new_password", type="password") }} {{ field::input(label="Confirm password", name="new_password_confirm", type="password") }} - {{ button::button(text="Change password", type="password", class="xl:col-span-2 place-self-end") }} + {{ button::button(text="Change password", type="submit", class="xl:col-span-2 place-self-end") }} {% endblock content %}