Email management UI

Also simplify a bunch of query strings
This commit is contained in:
Quentin Gliech
2022-01-18 18:16:56 +01:00
parent fdeb79a43e
commit 4384594fc2
17 changed files with 544 additions and 129 deletions

View File

@@ -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 () {

View File

@@ -20,7 +20,6 @@ use crate::traits::{StorageBackend, StorageBackendMarker};
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(bound = "T: StorageBackend")]
pub struct User<T: StorageBackend> {
#[serde(skip_serializing)]
pub data: T::UserData,
pub username: String,
pub sub: String,
@@ -73,7 +72,6 @@ impl<S: StorageBackendMarker> From<Authentication<S>> for Authentication<()> {
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(bound = "T: StorageBackend")]
pub struct BrowserSession<T: StorageBackend> {
#[serde(skip_serializing)]
pub data: T::BrowserSessionData,
pub user: User<T>,
pub created_at: DateTime<Utc>,
@@ -113,7 +111,6 @@ where
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(bound = "T: StorageBackend")]
pub struct UserEmail<T: StorageBackend> {
#[serde(skip_serializing)]
pub data: T::UserEmailData,
pub email: String,
pub created_at: DateTime<Utc>,

View File

@@ -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<S: StorageBackend> {
#[serde(
with = "serde_with::rust::display_fromstr",
bound(
deserialize = "S::AuthorizationGrantData: std::str::FromStr,
<S::AuthorizationGrantData as std::str::FromStr>::Err: std::fmt::Display",
serialize = "S::AuthorizationGrantData: std::fmt::Display"
)
)]
data: S::AuthorizationGrantData,
pub(crate) struct ContinueAuthorizationGrant {
data: String,
}
impl<S: StorageBackend> ContinueAuthorizationGrant<S> {
pub fn from_authorization_grant(grant: AuthorizationGrant<S>) -> Self {
Self { data: grant.data }
}
pub fn build_uri(&self) -> anyhow::Result<Uri>
impl ContinueAuthorizationGrant {
pub fn from_authorization_grant<S: StorageBackend>(grant: &AuthorizationGrant<S>) -> Self
where
S::AuthorizationGrantData: std::fmt::Display,
{
Self {
data: grant.data.to_string(),
}
}
pub fn build_uri(&self) -> anyhow::Result<Uri> {
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<S: StorageBackend> ContinueAuthorizationGrant<S> {
})?;
Ok(uri)
}
}
impl ContinueAuthorizationGrant<PostgresqlBackend> {
pub async fn fetch_authorization_grant(
&self,
executor: impl PgExecutor<'_>,
) -> anyhow::Result<AuthorizationGrant<PostgresqlBackend>> {
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<PostgresqlBackend>,
next: ContinueAuthorizationGrant,
browser_session: BrowserSession<PostgresqlBackend>,
mut txn: Transaction<'_, Postgres>,
) -> Result<ReplyOrBackToClient, Rejection> {
@@ -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)))

View File

@@ -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<dyn Reply>,)> {
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<PostgresqlBackend>,
mut conn: PoolConnection<Postgres>,
) -> Result<Box<dyn Reply>, Rejection> {
render(templates, cookie_saver, csrf_token, session, &mut conn).await
}
async fn render(
templates: Templates,
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
session: BrowserSession<PostgresqlBackend>,
executor: impl PgExecutor<'_>,
) -> Result<Box<dyn Reply>, 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<PostgresqlBackend>,
mut txn: Transaction<'_, Postgres>,
form: Form,
) -> Result<Box<dyn Reply>, 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)
}

View File

@@ -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()
}

View File

@@ -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,
<S::AuthorizationGrantData as std::str::FromStr>::Err: std::fmt::Display"))]
pub(crate) struct LoginRequest<S: StorageBackend> {
pub(crate) struct LoginRequest {
#[serde(flatten)]
post_auth_action: Option<PostAuthAction<S>>,
post_auth_action: Option<PostAuthAction>,
}
impl<S: StorageBackend> From<PostAuthAction<S>> for LoginRequest<S> {
fn from(post_auth_action: PostAuthAction<S>) -> Self {
impl From<PostAuthAction> for LoginRequest {
fn from(post_auth_action: PostAuthAction) -> Self {
Self {
post_auth_action: Some(post_auth_action),
}
}
}
impl<S: StorageBackend> LoginRequest<S> {
pub fn build_uri(&self) -> anyhow::Result<Uri>
where
S::AuthorizationGrantData: std::fmt::Display,
{
impl LoginRequest {
pub fn build_uri(&self) -> anyhow::Result<Uri> {
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<S: StorageBackend> LoginRequest<S> {
Ok(uri)
}
fn redirect(self) -> Result<impl Reply, Rejection>
where
S::AuthorizationGrantData: std::fmt::Display,
{
fn redirect(self) -> Result<impl Reply, Rejection> {
let uri = self
.post_auth_action
.as_ref()
@@ -121,7 +115,7 @@ async fn get(
mut conn: PoolConnection<Postgres>,
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
query: LoginRequest<PostgresqlBackend>,
query: LoginRequest,
maybe_session: Option<BrowserSession<PostgresqlBackend>>,
) -> Result<Box<dyn Reply>, Rejection> {
if maybe_session.is_some() {
@@ -153,7 +147,7 @@ async fn post(
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
form: LoginForm,
query: LoginRequest<PostgresqlBackend>,
query: LoginRequest,
) -> Result<Box<dyn Reply>, Rejection> {
use mas_storage::user::LoginError;
// TODO: recover
@@ -187,7 +181,7 @@ mod tests {
#[test]
fn deserialize_login_request() {
let res: Result<LoginRequest<PostgresqlBackend>, _> =
let res: Result<LoginRequest, _> =
serde_urlencoded::from_str("next=continue_authorization_grant&data=13");
res.unwrap().post_auth_action.unwrap();
}

View File

@@ -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,
<S::AuthorizationGrantData as std::str::FromStr>::Err: std::fmt::Display"))]
pub(crate) struct ReauthRequest<S: StorageBackend> {
pub(crate) struct ReauthRequest {
#[serde(flatten)]
post_auth_action: Option<PostAuthAction<S>>,
post_auth_action: Option<PostAuthAction>,
}
impl<S: StorageBackend> From<PostAuthAction<S>> for ReauthRequest<S> {
fn from(post_auth_action: PostAuthAction<S>) -> Self {
impl From<PostAuthAction> for ReauthRequest {
fn from(post_auth_action: PostAuthAction) -> Self {
Self {
post_auth_action: Some(post_auth_action),
}
}
}
impl<S: StorageBackend> ReauthRequest<S> {
pub fn build_uri(&self) -> anyhow::Result<Uri>
where
S::AuthorizationGrantData: std::fmt::Display,
{
impl ReauthRequest {
pub fn build_uri(&self) -> anyhow::Result<Uri> {
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<S: StorageBackend> ReauthRequest<S> {
Ok(uri)
}
fn redirect(self) -> Result<impl Reply, Rejection>
where
S::AuthorizationGrantData: std::fmt::Display,
{
fn redirect(self) -> Result<impl Reply, Rejection> {
let uri = self
.post_auth_action
.as_ref()
@@ -119,7 +111,7 @@ async fn get(
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
session: BrowserSession<PostgresqlBackend>,
query: ReauthRequest<PostgresqlBackend>,
query: ReauthRequest,
) -> Result<Box<dyn Reply>, Rejection> {
let ctx = ReauthContext::default();
let ctx = match query.post_auth_action {
@@ -141,7 +133,7 @@ async fn post(
mut session: BrowserSession<PostgresqlBackend>,
mut txn: Transaction<'_, Postgres>,
form: ReauthForm,
query: ReauthRequest<PostgresqlBackend>,
query: ReauthRequest,
) -> Result<Box<dyn Reply>, Rejection> {
// TODO: recover from errors here
authenticate_session(&mut txn, &mut session, form.password)

View File

@@ -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,
<S::AuthorizationGrantData as std::str::FromStr>::Err: std::fmt::Display"))]
pub struct RegisterRequest<S: StorageBackend> {
pub struct RegisterRequest {
#[serde(flatten)]
post_auth_action: Option<PostAuthAction<S>>,
post_auth_action: Option<PostAuthAction>,
}
impl<S: StorageBackend> From<PostAuthAction<S>> for RegisterRequest<S> {
fn from(post_auth_action: PostAuthAction<S>) -> Self {
impl From<PostAuthAction> for RegisterRequest {
fn from(post_auth_action: PostAuthAction) -> Self {
Self {
post_auth_action: Some(post_auth_action),
}
}
}
impl<S: StorageBackend> RegisterRequest<S> {
impl RegisterRequest {
#[allow(dead_code)]
pub fn build_uri(&self) -> anyhow::Result<Uri>
where
S::AuthorizationGrantData: std::fmt::Display,
{
pub fn build_uri(&self) -> anyhow::Result<Uri> {
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<S: StorageBackend> RegisterRequest<S> {
Ok(uri)
}
fn redirect(self) -> Result<impl Reply, Rejection>
where
S::AuthorizationGrantData: std::fmt::Display,
{
fn redirect(self) -> Result<impl Reply, Rejection> {
let uri = self
.post_auth_action
.as_ref()
@@ -125,7 +119,7 @@ async fn get(
mut conn: PoolConnection<Postgres>,
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
query: RegisterRequest<PostgresqlBackend>,
query: RegisterRequest,
maybe_session: Option<BrowserSession<PostgresqlBackend>>,
) -> Result<Box<dyn Reply>, Rejection> {
if maybe_session.is_some() {
@@ -153,7 +147,7 @@ async fn post(
mut txn: Transaction<'_, Postgres>,
cookie_saver: EncryptedCookieSaver,
form: RegisterForm,
query: RegisterRequest<PostgresqlBackend>,
query: RegisterRequest,
) -> Result<Box<dyn Reply>, Rejection> {
// TODO: display nice form errors
if form.password != form.password_confirm {

View File

@@ -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<S: StorageBackend> {
#[serde(bound(
deserialize = "S::AuthorizationGrantData: std::str::FromStr,
<S::AuthorizationGrantData as std::str::FromStr>::Err: std::fmt::Display",
serialize = "S::AuthorizationGrantData: std::fmt::Display"
))]
ContinueAuthorizationGrant(ContinueAuthorizationGrant<S>),
pub(crate) enum PostAuthAction {
ContinueAuthorizationGrant(ContinueAuthorizationGrant),
}
impl<S: StorageBackend> PostAuthAction<S> {
pub fn build_uri(&self) -> anyhow::Result<Uri>
where
S::AuthorizationGrantData: std::fmt::Display,
{
impl PostAuthAction {
pub fn build_uri(&self) -> anyhow::Result<Uri> {
match self {
PostAuthAction::ContinueAuthorizationGrant(c) => c.build_uri(),
}
}
}
impl<S: StorageBackend> From<ContinueAuthorizationGrant<S>> for PostAuthAction<S> {
fn from(g: ContinueAuthorizationGrant<S>) -> Self {
Self::ContinueAuthorizationGrant(g)
}
}
impl PostAuthAction<PostgresqlBackend> {
pub async fn load_context<'e>(
&self,
executor: impl PgExecutor<'e>,
@@ -63,3 +47,9 @@ impl PostAuthAction<PostgresqlBackend> {
}
}
}
impl From<ContinueAuthorizationGrant> for PostAuthAction {
fn from(g: ContinueAuthorizationGrant) -> Self {
Self::ContinueAuthorizationGrant(g)
}
}

View File

@@ -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<Utc>\"\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": {

View File

@@ -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<PostgresqlBackend>,
id: i64,
) -> Result<UserEmail<PostgresqlBackend>, 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<PostgresqlBackend>,
email: String,
) -> anyhow::Result<UserEmail<PostgresqlBackend>> {
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<PostgresqlBackend>,
) -> 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<PostgresqlBackend>,
) -> 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(())
}

View File

@@ -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<PostAuthContext>,
}
/// 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<T: StorageBackend> {
emails: Vec<UserEmail<T>>,
}
impl<T: StorageBackend> AccountEmailsContext<T> {
#[must_use]
pub fn new(emails: Vec<UserEmail<T>>) -> Self {
Self { emails }
}
}
impl<T: StorageBackend> TemplateContext for AccountEmailsContext<T> {
fn sample() -> Vec<Self>
where
Self: Sized,
{
let emails: Vec<UserEmail<T>> = UserEmail::samples();
vec![Self::new(emails)]
}
}
/// Context used by the `form_post.html` template
#[derive(Serialize)]
pub struct FormPostContext<T> {

View File

@@ -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<WithSession<EmptyContext>>) { "pages/account/password.html" }
/// Render the emails management
pub fn render_account_emails<T: StorageBackend>(WithCsrf<WithSession<AccountEmailsContext<T>>>) { "pages/account/emails.html" }
/// Render the re-authentication form
pub fn render_reauth(WithCsrf<WithSession<ReauthContext>>) { "pages/reauth.html" }

View File

@@ -42,14 +42,14 @@ limitations under the License.
<a class="{{ self::ghost_class() }} {{ class }}" href="{{ href }}">{{ text }}</a>
{% endmacro %}
{% macro button(text, name="", type="submit", class="") %}
<button name="{{ name }}" type="{{ type }}" class="{{ self::plain_class() }} {{ class }}">{{ text }}</button>
{% macro button(text, name="", type="submit", class="", value="") %}
<button name="{{ name }}" value="{{ value }}" type="{{ type }}" class="{{ self::plain_class() }} {{ class }}">{{ text }}</button>
{% endmacro %}
{% macro button_text(text, name="", type="submit", class="") %}
<button name="{{ name }}" type="{{ type }}" class="{{ self::text_class() }} {{ class }}">{{ text }}</button>
{% macro button_text(text, name="", type="submit", class="", value="") %}
<button name="{{ name }}" value="{{ value }}" type="{{ type }}" class="{{ self::text_class() }} {{ class }}">{{ text }}</button>
{% endmacro %}
{% macro button_ghost(text, name="", type="submit", class="") %}
<button name="{{ name }}" type="{{ type }}" class="{{ self::ghost_class() }} {{ class }}">{{ text }}</button>
{% macro button_ghost(text, name="", type="submit", class="", value="") %}
<button name="{{ name }}" value="{{ value }}" type="{{ type }}" class="{{ self::ghost_class() }} {{ class }}">{{ text }}</button>
{% endmacro %}

View File

@@ -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 %}
<section class="container mx-auto grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 p-2">
<form class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start" method="POST">
<h2 class="text-xl font-bold xl:col-span-2">Add email</h2>
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{{ 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") }}
</form>
<div class="rounded border-2 border-grey-50 dark:border-grey-450 xl:col-span-2 p-4">
<h2 class="text-xl font-bold xl:col-span-3">Emails</h2>
{% for item in emails %}
<form class="flex my-2 items-center justify-items-center" method="POST">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
<input type="hidden" name="data" value="{{ item.data }}" />
<div class="font-bold flex-1">{{ item.email }}</div>
{% if item.confirmed_at %}
<div class="mr-4">Verified</div>
{% else %}
{{ button::button(text="Resend verification", type="submit", name="action", value="resend_confirmation", class="mr-4") }}
{% endif %}
{% if item.email == primary_email %}
<div class="mr-4">Primary</div>
{% 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") }}
</form>
{% endfor %}
</div>
</section>
{% endblock content %}

View File

@@ -52,6 +52,7 @@ limitations under the License.
<div class="font-bold">{{ email.email }}</div>
<div>{% if email.confirmed_at %}Confirmed{% else %}Unconfirmed{% endif %}</div>
{% endfor %}
{{ button::link_ghost(text="Manage", href="/account/emails", class="col-span-2 place-self-end") }}
</div>
</section>
{% endblock content %}

View File

@@ -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") }}
</form>
</section>
{% endblock content %}