Email management UI
Also simplify a bunch of query strings
This commit is contained in:
@@ -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 () {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)))
|
||||
|
||||
156
crates/handlers/src/views/account/emails.rs
Normal file
156
crates/handlers/src/views/account/emails.rs
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
59
crates/templates/src/res/pages/account/emails.html
Normal file
59
crates/templates/src/res/pages/account/emails.html
Normal 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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user