You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-07-31 09:24:31 +03:00
Save user emails in database
This commit is contained in:
@ -33,5 +33,5 @@ pub use self::{
|
|||||||
},
|
},
|
||||||
tokens::{AccessToken, RefreshToken, TokenFormatError, TokenType},
|
tokens::{AccessToken, RefreshToken, TokenFormatError, TokenType},
|
||||||
traits::{StorageBackend, StorageBackendMarker},
|
traits::{StorageBackend, StorageBackendMarker},
|
||||||
users::{Authentication, BrowserSession, User},
|
users::{Authentication, BrowserSession, User, UserEmail},
|
||||||
};
|
};
|
||||||
|
@ -16,6 +16,7 @@ pub trait StorageBackendMarker: StorageBackend {}
|
|||||||
|
|
||||||
pub trait StorageBackend {
|
pub trait StorageBackend {
|
||||||
type UserData: Clone + std::fmt::Debug + PartialEq;
|
type UserData: Clone + std::fmt::Debug + PartialEq;
|
||||||
|
type UserEmailData: Clone + std::fmt::Debug + PartialEq;
|
||||||
type AuthenticationData: Clone + std::fmt::Debug + PartialEq;
|
type AuthenticationData: Clone + std::fmt::Debug + PartialEq;
|
||||||
type BrowserSessionData: Clone + std::fmt::Debug + PartialEq;
|
type BrowserSessionData: Clone + std::fmt::Debug + PartialEq;
|
||||||
type ClientData: Clone + std::fmt::Debug + PartialEq;
|
type ClientData: Clone + std::fmt::Debug + PartialEq;
|
||||||
@ -34,4 +35,5 @@ impl StorageBackend for () {
|
|||||||
type RefreshTokenData = ();
|
type RefreshTokenData = ();
|
||||||
type SessionData = ();
|
type SessionData = ();
|
||||||
type UserData = ();
|
type UserData = ();
|
||||||
|
type UserEmailData = ();
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ pub struct User<T: StorageBackend> {
|
|||||||
pub data: T::UserData,
|
pub data: T::UserData,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub sub: String,
|
pub sub: String,
|
||||||
|
pub primary_email: Option<UserEmail<T>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: StorageBackend> User<T>
|
impl<T: StorageBackend> User<T>
|
||||||
@ -36,6 +37,7 @@ where
|
|||||||
data: Default::default(),
|
data: Default::default(),
|
||||||
username: "john".to_string(),
|
username: "john".to_string(),
|
||||||
sub: "123-456".to_string(),
|
sub: "123-456".to_string(),
|
||||||
|
primary_email: None,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -46,6 +48,7 @@ impl<S: StorageBackendMarker> From<User<S>> for User<()> {
|
|||||||
data: (),
|
data: (),
|
||||||
username: u.username,
|
username: u.username,
|
||||||
sub: u.sub,
|
sub: u.sub,
|
||||||
|
primary_email: u.primary_email.map(Into::into),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -106,3 +109,47 @@ where
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
pub confirmed_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: StorageBackendMarker> From<UserEmail<S>> for UserEmail<()> {
|
||||||
|
fn from(e: UserEmail<S>) -> Self {
|
||||||
|
UserEmail {
|
||||||
|
data: (),
|
||||||
|
email: e.email,
|
||||||
|
created_at: e.created_at,
|
||||||
|
confirmed_at: e.confirmed_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: StorageBackend> UserEmail<T>
|
||||||
|
where
|
||||||
|
T::UserEmailData: Default,
|
||||||
|
{
|
||||||
|
#[must_use]
|
||||||
|
pub fn samples() -> Vec<Self> {
|
||||||
|
vec![
|
||||||
|
UserEmail {
|
||||||
|
data: T::UserEmailData::default(),
|
||||||
|
email: "alice@example.com".to_string(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
confirmed_at: Some(Utc::now()),
|
||||||
|
},
|
||||||
|
UserEmail {
|
||||||
|
data: T::UserEmailData::default(),
|
||||||
|
email: "bob@example.com".to_string(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
confirmed_at: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -16,7 +16,7 @@ use argon2::Argon2;
|
|||||||
use mas_config::{CookiesConfig, CsrfConfig};
|
use mas_config::{CookiesConfig, CsrfConfig};
|
||||||
use mas_data_model::BrowserSession;
|
use mas_data_model::BrowserSession;
|
||||||
use mas_storage::{
|
use mas_storage::{
|
||||||
user::{authenticate_session, count_active_sessions, set_password},
|
user::{authenticate_session, count_active_sessions, get_user_emails, set_password},
|
||||||
PostgresqlBackend,
|
PostgresqlBackend,
|
||||||
};
|
};
|
||||||
use mas_templates::{AccountContext, TemplateContext, Templates};
|
use mas_templates::{AccountContext, TemplateContext, Templates};
|
||||||
@ -25,13 +25,13 @@ use mas_warp_utils::{
|
|||||||
filters::{
|
filters::{
|
||||||
cookies::{encrypted_cookie_saver, EncryptedCookieSaver},
|
cookies::{encrypted_cookie_saver, EncryptedCookieSaver},
|
||||||
csrf::{protected_form, updated_csrf_token},
|
csrf::{protected_form, updated_csrf_token},
|
||||||
database::{connection, transaction},
|
database::transaction,
|
||||||
session::session,
|
session::session,
|
||||||
with_templates, CsrfToken,
|
with_templates, CsrfToken,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::{pool::PoolConnection, PgExecutor, PgPool, Postgres, Transaction};
|
use sqlx::{PgPool, Postgres, Transaction};
|
||||||
use warp::{filters::BoxedFilter, reply::html, Filter, Rejection, Reply};
|
use warp::{filters::BoxedFilter, reply::html, Filter, Rejection, Reply};
|
||||||
|
|
||||||
pub(super) fn filter(
|
pub(super) fn filter(
|
||||||
@ -44,7 +44,7 @@ pub(super) fn filter(
|
|||||||
.and(encrypted_cookie_saver(cookies_config))
|
.and(encrypted_cookie_saver(cookies_config))
|
||||||
.and(updated_csrf_token(cookies_config, csrf_config))
|
.and(updated_csrf_token(cookies_config, csrf_config))
|
||||||
.and(session(pool, cookies_config))
|
.and(session(pool, cookies_config))
|
||||||
.and(connection(pool))
|
.and(transaction(pool))
|
||||||
.and_then(get);
|
.and_then(get);
|
||||||
|
|
||||||
let post = with_templates(templates)
|
let post = with_templates(templates)
|
||||||
@ -71,9 +71,9 @@ async fn get(
|
|||||||
cookie_saver: EncryptedCookieSaver,
|
cookie_saver: EncryptedCookieSaver,
|
||||||
csrf_token: CsrfToken,
|
csrf_token: CsrfToken,
|
||||||
session: BrowserSession<PostgresqlBackend>,
|
session: BrowserSession<PostgresqlBackend>,
|
||||||
mut conn: PoolConnection<Postgres>,
|
txn: Transaction<'_, Postgres>,
|
||||||
) -> Result<Box<dyn Reply>, Rejection> {
|
) -> Result<Box<dyn Reply>, Rejection> {
|
||||||
render(templates, cookie_saver, csrf_token, session, &mut conn).await
|
render(templates, cookie_saver, csrf_token, session, txn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn render(
|
async fn render(
|
||||||
@ -81,18 +81,26 @@ async fn render(
|
|||||||
cookie_saver: EncryptedCookieSaver,
|
cookie_saver: EncryptedCookieSaver,
|
||||||
csrf_token: CsrfToken,
|
csrf_token: CsrfToken,
|
||||||
session: BrowserSession<PostgresqlBackend>,
|
session: BrowserSession<PostgresqlBackend>,
|
||||||
executor: impl PgExecutor<'_>,
|
mut txn: Transaction<'_, Postgres>,
|
||||||
) -> Result<Box<dyn Reply>, Rejection> {
|
) -> Result<Box<dyn Reply>, Rejection> {
|
||||||
let active_sessions = count_active_sessions(executor, &session.user)
|
let active_sessions = count_active_sessions(&mut txn, &session.user)
|
||||||
.await
|
.await
|
||||||
.wrap_error()?;
|
.wrap_error()?;
|
||||||
let ctx = AccountContext::new(active_sessions)
|
|
||||||
|
let emails = get_user_emails(&mut txn, &session.user)
|
||||||
|
.await
|
||||||
|
.wrap_error()?;
|
||||||
|
|
||||||
|
txn.commit().await.wrap_error()?;
|
||||||
|
|
||||||
|
let ctx = AccountContext::new(active_sessions, emails)
|
||||||
.with_session(session)
|
.with_session(session)
|
||||||
.with_csrf(csrf_token.form_value());
|
.with_csrf(csrf_token.form_value());
|
||||||
|
|
||||||
let content = templates.render_account(&ctx).await?;
|
let content = templates.render_account(&ctx).await?;
|
||||||
let reply = html(content);
|
let reply = html(content);
|
||||||
let reply = cookie_saver.save_encrypted(&csrf_token, reply)?;
|
let reply = cookie_saver.save_encrypted(&csrf_token, reply)?;
|
||||||
|
|
||||||
Ok(Box::new(reply))
|
Ok(Box::new(reply))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,9 +126,7 @@ async fn post(
|
|||||||
.await
|
.await
|
||||||
.wrap_error()?;
|
.wrap_error()?;
|
||||||
|
|
||||||
let reply = render(templates, cookie_saver, csrf_token, session, &mut txn).await?;
|
let reply = render(templates, cookie_saver, csrf_token, session, txn).await?;
|
||||||
|
|
||||||
txn.commit().await.wrap_error()?;
|
|
||||||
|
|
||||||
Ok(reply)
|
Ok(reply)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
-- Copyright 2021 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.
|
||||||
|
|
||||||
|
DROP TABLE user_emails;
|
24
crates/storage/migrations/20220114150141_user_emails.up.sql
Normal file
24
crates/storage/migrations/20220114150141_user_emails.up.sql
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
-- Copyright 2021 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.
|
||||||
|
|
||||||
|
CREATE TABLE user_emails (
|
||||||
|
"id" BIGSERIAL PRIMARY KEY,
|
||||||
|
"user_id" BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
"confirmed_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN "primary_email_id" BIGINT REFERENCES user_emails (id) ON DELETE SET NULL;
|
File diff suppressed because it is too large
Load Diff
@ -38,6 +38,7 @@ impl StorageBackend for PostgresqlBackend {
|
|||||||
type RefreshTokenData = i64;
|
type RefreshTokenData = i64;
|
||||||
type SessionData = i64;
|
type SessionData = i64;
|
||||||
type UserData = i64;
|
type UserData = i64;
|
||||||
|
type UserEmailData = i64;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StorageBackendMarker for PostgresqlBackend {}
|
impl StorageBackendMarker for PostgresqlBackend {}
|
||||||
|
@ -14,7 +14,9 @@
|
|||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use mas_data_model::{AccessToken, Authentication, BrowserSession, Client, Session, User};
|
use mas_data_model::{
|
||||||
|
AccessToken, Authentication, BrowserSession, Client, Session, User, UserEmail,
|
||||||
|
};
|
||||||
use sqlx::PgExecutor;
|
use sqlx::PgExecutor;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@ -71,6 +73,10 @@ pub struct OAuth2AccessTokenLookup {
|
|||||||
user_username: String,
|
user_username: String,
|
||||||
user_session_last_authentication_id: Option<i64>,
|
user_session_last_authentication_id: Option<i64>,
|
||||||
user_session_last_authentication_created_at: Option<DateTime<Utc>>,
|
user_session_last_authentication_created_at: Option<DateTime<Utc>>,
|
||||||
|
user_email_id: Option<i64>,
|
||||||
|
user_email: Option<String>,
|
||||||
|
user_email_created_at: Option<DateTime<Utc>>,
|
||||||
|
user_email_confirmed_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@ -83,10 +89,7 @@ pub enum AccessTokenLookupError {
|
|||||||
impl AccessTokenLookupError {
|
impl AccessTokenLookupError {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn not_found(&self) -> bool {
|
pub fn not_found(&self) -> bool {
|
||||||
matches!(
|
matches!(self, Self::Database(sqlx::Error::RowNotFound))
|
||||||
self,
|
|
||||||
&AccessTokenLookupError::Database(sqlx::Error::RowNotFound)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +113,11 @@ pub async fn lookup_active_access_token(
|
|||||||
u.id AS "user_id!",
|
u.id AS "user_id!",
|
||||||
u.username AS "user_username!",
|
u.username AS "user_username!",
|
||||||
usa.id AS "user_session_last_authentication_id?",
|
usa.id AS "user_session_last_authentication_id?",
|
||||||
usa.created_at AS "user_session_last_authentication_created_at?"
|
usa.created_at AS "user_session_last_authentication_created_at?",
|
||||||
|
ue.id AS "user_email_id?",
|
||||||
|
ue.email AS "user_email?",
|
||||||
|
ue.created_at AS "user_email_created_at?",
|
||||||
|
ue.confirmed_at AS "user_email_confirmed_at?"
|
||||||
|
|
||||||
FROM oauth2_access_tokens at
|
FROM oauth2_access_tokens at
|
||||||
INNER JOIN oauth2_sessions os
|
INNER JOIN oauth2_sessions os
|
||||||
@ -121,6 +128,8 @@ pub async fn lookup_active_access_token(
|
|||||||
ON u.id = us.user_id
|
ON u.id = us.user_id
|
||||||
LEFT JOIN user_session_authentications usa
|
LEFT JOIN user_session_authentications usa
|
||||||
ON usa.session_id = us.id
|
ON usa.session_id = us.id
|
||||||
|
LEFT JOIN user_emails ue
|
||||||
|
ON ue.id = u.primary_email_id
|
||||||
|
|
||||||
WHERE at.token = $1
|
WHERE at.token = $1
|
||||||
AND at.created_at + (at.expires_after * INTERVAL '1 second') >= now()
|
AND at.created_at + (at.expires_after * INTERVAL '1 second') >= now()
|
||||||
@ -148,10 +157,27 @@ pub async fn lookup_active_access_token(
|
|||||||
client_id: res.client_id,
|
client_id: res.client_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let primary_email = match (
|
||||||
|
res.user_email_id,
|
||||||
|
res.user_email,
|
||||||
|
res.user_email_created_at,
|
||||||
|
res.user_email_confirmed_at,
|
||||||
|
) {
|
||||||
|
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
|
||||||
|
data: id,
|
||||||
|
email,
|
||||||
|
created_at,
|
||||||
|
confirmed_at,
|
||||||
|
}),
|
||||||
|
(None, None, None, None) => None,
|
||||||
|
_ => return Err(DatabaseInconsistencyError.into()),
|
||||||
|
};
|
||||||
|
|
||||||
let user = User {
|
let user = User {
|
||||||
data: res.user_id,
|
data: res.user_id,
|
||||||
username: res.user_username,
|
username: res.user_username,
|
||||||
sub: format!("fake-sub-{}", res.user_id),
|
sub: format!("fake-sub-{}", res.user_id),
|
||||||
|
primary_email,
|
||||||
};
|
};
|
||||||
|
|
||||||
let last_authentication = match (
|
let last_authentication = match (
|
||||||
|
@ -20,7 +20,7 @@ use anyhow::Context;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use mas_data_model::{
|
use mas_data_model::{
|
||||||
Authentication, AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, BrowserSession,
|
Authentication, AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, BrowserSession,
|
||||||
Client, Pkce, Session, User,
|
Client, Pkce, Session, User, UserEmail,
|
||||||
};
|
};
|
||||||
use mas_iana::oauth::PkceCodeChallengeMethod;
|
use mas_iana::oauth::PkceCodeChallengeMethod;
|
||||||
use oauth2_types::{requests::ResponseMode, scope::Scope};
|
use oauth2_types::{requests::ResponseMode, scope::Scope};
|
||||||
@ -135,6 +135,10 @@ struct GrantLookup {
|
|||||||
user_username: Option<String>,
|
user_username: Option<String>,
|
||||||
user_session_last_authentication_id: Option<i64>,
|
user_session_last_authentication_id: Option<i64>,
|
||||||
user_session_last_authentication_created_at: Option<DateTime<Utc>>,
|
user_session_last_authentication_created_at: Option<DateTime<Utc>>,
|
||||||
|
user_email_id: Option<i64>,
|
||||||
|
user_email: Option<String>,
|
||||||
|
user_email_created_at: Option<DateTime<Utc>>,
|
||||||
|
user_email_confirmed_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryInto<AuthorizationGrant<PostgresqlBackend>> for GrantLookup {
|
impl TryInto<AuthorizationGrant<PostgresqlBackend>> for GrantLookup {
|
||||||
@ -164,6 +168,22 @@ impl TryInto<AuthorizationGrant<PostgresqlBackend>> for GrantLookup {
|
|||||||
_ => return Err(DatabaseInconsistencyError),
|
_ => return Err(DatabaseInconsistencyError),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let primary_email = match (
|
||||||
|
self.user_email_id,
|
||||||
|
self.user_email,
|
||||||
|
self.user_email_created_at,
|
||||||
|
self.user_email_confirmed_at,
|
||||||
|
) {
|
||||||
|
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
|
||||||
|
data: id,
|
||||||
|
email,
|
||||||
|
created_at,
|
||||||
|
confirmed_at,
|
||||||
|
}),
|
||||||
|
(None, None, None, None) => None,
|
||||||
|
_ => return Err(DatabaseInconsistencyError),
|
||||||
|
};
|
||||||
|
|
||||||
let session = match (
|
let session = match (
|
||||||
self.session_id,
|
self.session_id,
|
||||||
self.user_session_id,
|
self.user_session_id,
|
||||||
@ -171,6 +191,7 @@ impl TryInto<AuthorizationGrant<PostgresqlBackend>> for GrantLookup {
|
|||||||
self.user_id,
|
self.user_id,
|
||||||
self.user_username,
|
self.user_username,
|
||||||
last_authentication,
|
last_authentication,
|
||||||
|
primary_email,
|
||||||
) {
|
) {
|
||||||
(
|
(
|
||||||
Some(session_id),
|
Some(session_id),
|
||||||
@ -179,11 +200,13 @@ impl TryInto<AuthorizationGrant<PostgresqlBackend>> for GrantLookup {
|
|||||||
Some(user_id),
|
Some(user_id),
|
||||||
Some(user_username),
|
Some(user_username),
|
||||||
last_authentication,
|
last_authentication,
|
||||||
|
primary_email,
|
||||||
) => {
|
) => {
|
||||||
let user = User {
|
let user = User {
|
||||||
data: user_id,
|
data: user_id,
|
||||||
username: user_username,
|
username: user_username,
|
||||||
sub: format!("fake-sub-{}", user_id),
|
sub: format!("fake-sub-{}", user_id),
|
||||||
|
primary_email,
|
||||||
};
|
};
|
||||||
|
|
||||||
let browser_session = BrowserSession {
|
let browser_session = BrowserSession {
|
||||||
@ -205,7 +228,7 @@ impl TryInto<AuthorizationGrant<PostgresqlBackend>> for GrantLookup {
|
|||||||
|
|
||||||
Some(session)
|
Some(session)
|
||||||
}
|
}
|
||||||
(None, None, None, None, None, None) => None,
|
(None, None, None, None, None, None, None) => None,
|
||||||
_ => return Err(DatabaseInconsistencyError),
|
_ => return Err(DatabaseInconsistencyError),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -333,7 +356,11 @@ pub async fn get_grant_by_id(
|
|||||||
u.id AS "user_id?",
|
u.id AS "user_id?",
|
||||||
u.username AS "user_username?",
|
u.username AS "user_username?",
|
||||||
usa.id AS "user_session_last_authentication_id?",
|
usa.id AS "user_session_last_authentication_id?",
|
||||||
usa.created_at AS "user_session_last_authentication_created_at?"
|
usa.created_at AS "user_session_last_authentication_created_at?",
|
||||||
|
ue.id AS "user_email_id?",
|
||||||
|
ue.email AS "user_email?",
|
||||||
|
ue.created_at AS "user_email_created_at?",
|
||||||
|
ue.confirmed_at AS "user_email_confirmed_at?"
|
||||||
FROM
|
FROM
|
||||||
oauth2_authorization_grants og
|
oauth2_authorization_grants og
|
||||||
LEFT JOIN oauth2_sessions os
|
LEFT JOIN oauth2_sessions os
|
||||||
@ -344,8 +371,10 @@ pub async fn get_grant_by_id(
|
|||||||
ON u.id = us.user_id
|
ON u.id = us.user_id
|
||||||
LEFT JOIN user_session_authentications usa
|
LEFT JOIN user_session_authentications usa
|
||||||
ON usa.session_id = us.id
|
ON usa.session_id = us.id
|
||||||
WHERE
|
LEFT JOIN user_emails ue
|
||||||
og.id = $1
|
ON ue.id = u.primary_email_id
|
||||||
|
|
||||||
|
WHERE og.id = $1
|
||||||
|
|
||||||
ORDER BY usa.created_at DESC
|
ORDER BY usa.created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@ -395,7 +424,11 @@ pub async fn lookup_grant_by_code(
|
|||||||
u.id AS "user_id?",
|
u.id AS "user_id?",
|
||||||
u.username AS "user_username?",
|
u.username AS "user_username?",
|
||||||
usa.id AS "user_session_last_authentication_id?",
|
usa.id AS "user_session_last_authentication_id?",
|
||||||
usa.created_at AS "user_session_last_authentication_created_at?"
|
usa.created_at AS "user_session_last_authentication_created_at?",
|
||||||
|
ue.id AS "user_email_id?",
|
||||||
|
ue.email AS "user_email?",
|
||||||
|
ue.created_at AS "user_email_created_at?",
|
||||||
|
ue.confirmed_at AS "user_email_confirmed_at?"
|
||||||
FROM
|
FROM
|
||||||
oauth2_authorization_grants og
|
oauth2_authorization_grants og
|
||||||
LEFT JOIN oauth2_sessions os
|
LEFT JOIN oauth2_sessions os
|
||||||
@ -406,8 +439,10 @@ pub async fn lookup_grant_by_code(
|
|||||||
ON u.id = us.user_id
|
ON u.id = us.user_id
|
||||||
LEFT JOIN user_session_authentications usa
|
LEFT JOIN user_session_authentications usa
|
||||||
ON usa.session_id = us.id
|
ON usa.session_id = us.id
|
||||||
WHERE
|
LEFT JOIN user_emails ue
|
||||||
og.code = $1
|
ON ue.id = u.primary_email_id
|
||||||
|
|
||||||
|
WHERE og.code = $1
|
||||||
|
|
||||||
ORDER BY usa.created_at DESC
|
ORDER BY usa.created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use mas_data_model::{
|
use mas_data_model::{
|
||||||
AccessToken, Authentication, BrowserSession, Client, RefreshToken, Session, User,
|
AccessToken, Authentication, BrowserSession, Client, RefreshToken, Session, User, UserEmail,
|
||||||
};
|
};
|
||||||
use sqlx::PgExecutor;
|
use sqlx::PgExecutor;
|
||||||
|
|
||||||
@ -70,6 +70,10 @@ struct OAuth2RefreshTokenLookup {
|
|||||||
user_username: String,
|
user_username: String,
|
||||||
user_session_last_authentication_id: Option<i64>,
|
user_session_last_authentication_id: Option<i64>,
|
||||||
user_session_last_authentication_created_at: Option<DateTime<Utc>>,
|
user_session_last_authentication_created_at: Option<DateTime<Utc>>,
|
||||||
|
user_email_id: Option<i64>,
|
||||||
|
user_email: Option<String>,
|
||||||
|
user_email_created_at: Option<DateTime<Utc>>,
|
||||||
|
user_email_confirmed_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
@ -96,7 +100,11 @@ pub async fn lookup_active_refresh_token(
|
|||||||
u.id AS "user_id!",
|
u.id AS "user_id!",
|
||||||
u.username AS "user_username!",
|
u.username AS "user_username!",
|
||||||
usa.id AS "user_session_last_authentication_id?",
|
usa.id AS "user_session_last_authentication_id?",
|
||||||
usa.created_at AS "user_session_last_authentication_created_at?"
|
usa.created_at AS "user_session_last_authentication_created_at?",
|
||||||
|
ue.id AS "user_email_id?",
|
||||||
|
ue.email AS "user_email?",
|
||||||
|
ue.created_at AS "user_email_created_at?",
|
||||||
|
ue.confirmed_at AS "user_email_confirmed_at?"
|
||||||
FROM oauth2_refresh_tokens rt
|
FROM oauth2_refresh_tokens rt
|
||||||
LEFT JOIN oauth2_access_tokens at
|
LEFT JOIN oauth2_access_tokens at
|
||||||
ON at.id = rt.oauth2_access_token_id
|
ON at.id = rt.oauth2_access_token_id
|
||||||
@ -108,6 +116,8 @@ pub async fn lookup_active_refresh_token(
|
|||||||
ON u.id = us.user_id
|
ON u.id = us.user_id
|
||||||
LEFT JOIN user_session_authentications usa
|
LEFT JOIN user_session_authentications usa
|
||||||
ON usa.session_id = us.id
|
ON usa.session_id = us.id
|
||||||
|
LEFT JOIN user_emails ue
|
||||||
|
ON ue.id = u.primary_email_id
|
||||||
|
|
||||||
WHERE rt.token = $1
|
WHERE rt.token = $1
|
||||||
AND rt.next_token_id IS NULL
|
AND rt.next_token_id IS NULL
|
||||||
@ -152,10 +162,27 @@ pub async fn lookup_active_refresh_token(
|
|||||||
client_id: res.client_id,
|
client_id: res.client_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let primary_email = match (
|
||||||
|
res.user_email_id,
|
||||||
|
res.user_email,
|
||||||
|
res.user_email_created_at,
|
||||||
|
res.user_email_confirmed_at,
|
||||||
|
) {
|
||||||
|
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
|
||||||
|
data: id,
|
||||||
|
email,
|
||||||
|
created_at,
|
||||||
|
confirmed_at,
|
||||||
|
}),
|
||||||
|
(None, None, None, None) => None,
|
||||||
|
_ => return Err(DatabaseInconsistencyError.into()),
|
||||||
|
};
|
||||||
|
|
||||||
let user = User {
|
let user = User {
|
||||||
data: res.user_id,
|
data: res.user_id,
|
||||||
username: res.user_username,
|
username: res.user_username,
|
||||||
sub: format!("fake-sub-{}", res.user_id),
|
sub: format!("fake-sub-{}", res.user_id),
|
||||||
|
primary_email,
|
||||||
};
|
};
|
||||||
|
|
||||||
let last_authentication = match (
|
let last_authentication = match (
|
||||||
|
@ -17,7 +17,7 @@ use std::borrow::BorrowMut;
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use argon2::Argon2;
|
use argon2::Argon2;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use mas_data_model::{errors::HtmlError, Authentication, BrowserSession, User};
|
use mas_data_model::{errors::HtmlError, Authentication, BrowserSession, User, UserEmail};
|
||||||
use password_hash::{PasswordHash, PasswordHasher, SaltString};
|
use password_hash::{PasswordHash, PasswordHasher, SaltString};
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use sqlx::{Acquire, PgExecutor, Postgres, Transaction};
|
use sqlx::{Acquire, PgExecutor, Postgres, Transaction};
|
||||||
@ -31,8 +31,12 @@ use crate::IdAndCreationTime;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct UserLookup {
|
struct UserLookup {
|
||||||
pub id: i64,
|
user_id: i64,
|
||||||
pub username: String,
|
user_username: String,
|
||||||
|
user_email_id: Option<i64>,
|
||||||
|
user_email: Option<String>,
|
||||||
|
user_email_created_at: Option<DateTime<Utc>>,
|
||||||
|
user_email_confirmed_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@ -41,7 +45,7 @@ pub enum LoginError {
|
|||||||
NotFound {
|
NotFound {
|
||||||
username: String,
|
username: String,
|
||||||
#[source]
|
#[source]
|
||||||
source: sqlx::Error,
|
source: UserLookupError,
|
||||||
},
|
},
|
||||||
|
|
||||||
#[error("authentication failed for {username:?}")]
|
#[error("authentication failed for {username:?}")]
|
||||||
@ -75,7 +79,7 @@ pub async fn login(
|
|||||||
let user = lookup_user_by_username(&mut txn, username)
|
let user = lookup_user_by_username(&mut txn, username)
|
||||||
.await
|
.await
|
||||||
.map_err(|source| {
|
.map_err(|source| {
|
||||||
if matches!(source, sqlx::Error::RowNotFound) {
|
if source.not_found() {
|
||||||
LoginError::NotFound {
|
LoginError::NotFound {
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
source,
|
source,
|
||||||
@ -115,10 +119,7 @@ impl Reject for ActiveSessionLookupError {}
|
|||||||
impl ActiveSessionLookupError {
|
impl ActiveSessionLookupError {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn not_found(&self) -> bool {
|
pub fn not_found(&self) -> bool {
|
||||||
matches!(
|
matches!(self, Self::Fetch(sqlx::Error::RowNotFound))
|
||||||
self,
|
|
||||||
ActiveSessionLookupError::Fetch(sqlx::Error::RowNotFound)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,16 +130,37 @@ struct SessionLookup {
|
|||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
last_authentication_id: Option<i64>,
|
last_authentication_id: Option<i64>,
|
||||||
last_authd_at: Option<DateTime<Utc>>,
|
last_authd_at: Option<DateTime<Utc>>,
|
||||||
|
user_email_id: Option<i64>,
|
||||||
|
user_email: Option<String>,
|
||||||
|
user_email_created_at: Option<DateTime<Utc>>,
|
||||||
|
user_email_confirmed_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryInto<BrowserSession<PostgresqlBackend>> for SessionLookup {
|
impl TryInto<BrowserSession<PostgresqlBackend>> for SessionLookup {
|
||||||
type Error = DatabaseInconsistencyError;
|
type Error = DatabaseInconsistencyError;
|
||||||
|
|
||||||
fn try_into(self) -> Result<BrowserSession<PostgresqlBackend>, Self::Error> {
|
fn try_into(self) -> Result<BrowserSession<PostgresqlBackend>, Self::Error> {
|
||||||
|
let primary_email = match (
|
||||||
|
self.user_email_id,
|
||||||
|
self.user_email,
|
||||||
|
self.user_email_created_at,
|
||||||
|
self.user_email_confirmed_at,
|
||||||
|
) {
|
||||||
|
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
|
||||||
|
data: id,
|
||||||
|
email,
|
||||||
|
created_at,
|
||||||
|
confirmed_at,
|
||||||
|
}),
|
||||||
|
(None, None, None, None) => None,
|
||||||
|
_ => return Err(DatabaseInconsistencyError),
|
||||||
|
};
|
||||||
|
|
||||||
let user = User {
|
let user = User {
|
||||||
data: self.user_id,
|
data: self.user_id,
|
||||||
username: self.username,
|
username: self.username,
|
||||||
sub: format!("fake-sub-{}", self.user_id),
|
sub: format!("fake-sub-{}", self.user_id),
|
||||||
|
primary_email,
|
||||||
};
|
};
|
||||||
|
|
||||||
let last_authentication = match (self.last_authentication_id, self.last_authd_at) {
|
let last_authentication = match (self.last_authentication_id, self.last_authd_at) {
|
||||||
@ -169,16 +191,22 @@ pub async fn lookup_active_session(
|
|||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
s.id,
|
s.id,
|
||||||
u.id as user_id,
|
u.id AS user_id,
|
||||||
u.username,
|
u.username,
|
||||||
s.created_at,
|
s.created_at,
|
||||||
a.id as "last_authentication_id?",
|
a.id AS "last_authentication_id?",
|
||||||
a.created_at as "last_authd_at?"
|
a.created_at AS "last_authd_at?",
|
||||||
|
ue.id AS "user_email_id?",
|
||||||
|
ue.email AS "user_email?",
|
||||||
|
ue.created_at AS "user_email_created_at?",
|
||||||
|
ue.confirmed_at AS "user_email_confirmed_at?"
|
||||||
FROM user_sessions s
|
FROM user_sessions s
|
||||||
INNER JOIN users u
|
INNER JOIN users u
|
||||||
ON s.user_id = u.id
|
ON s.user_id = u.id
|
||||||
LEFT JOIN user_session_authentications a
|
LEFT JOIN user_session_authentications a
|
||||||
ON a.session_id = s.id
|
ON a.session_id = s.id
|
||||||
|
LEFT JOIN user_emails ue
|
||||||
|
ON ue.id = u.primary_email_id
|
||||||
WHERE s.id = $1 AND s.active
|
WHERE s.id = $1 AND s.active
|
||||||
ORDER BY a.created_at DESC
|
ORDER BY a.created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@ -336,6 +364,7 @@ pub async fn register_user(
|
|||||||
data: id,
|
data: id,
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
sub: format!("fake-sub-{}", id),
|
sub: format!("fake-sub-{}", id),
|
||||||
|
primary_email: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
set_password(txn.borrow_mut(), phf, &user, password).await?;
|
set_password(txn.borrow_mut(), phf, &user, password).await?;
|
||||||
@ -390,17 +419,41 @@ pub async fn end_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[error("failed to lookup user")]
|
||||||
|
pub enum UserLookupError {
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
Inconsistency(#[from] DatabaseInconsistencyError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserLookupError {
|
||||||
|
#[must_use]
|
||||||
|
pub fn not_found(&self) -> bool {
|
||||||
|
matches!(self, Self::Database(sqlx::Error::RowNotFound))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(executor))]
|
#[tracing::instrument(skip(executor))]
|
||||||
pub async fn lookup_user_by_username(
|
pub async fn lookup_user_by_username(
|
||||||
executor: impl PgExecutor<'_>,
|
executor: impl PgExecutor<'_>,
|
||||||
username: &str,
|
username: &str,
|
||||||
) -> Result<User<PostgresqlBackend>, sqlx::Error> {
|
) -> Result<User<PostgresqlBackend>, UserLookupError> {
|
||||||
let res = sqlx::query_as!(
|
let res = sqlx::query_as!(
|
||||||
UserLookup,
|
UserLookup,
|
||||||
r#"
|
r#"
|
||||||
SELECT id, username
|
SELECT
|
||||||
FROM users
|
u.id AS user_id,
|
||||||
WHERE username = $1
|
u.username AS user_username,
|
||||||
|
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 users u
|
||||||
|
|
||||||
|
LEFT JOIN user_emails ue
|
||||||
|
ON ue.id = u.primary_email_id
|
||||||
|
|
||||||
|
WHERE u.username = $1
|
||||||
"#,
|
"#,
|
||||||
username,
|
username,
|
||||||
)
|
)
|
||||||
@ -408,9 +461,73 @@ pub async fn lookup_user_by_username(
|
|||||||
.instrument(info_span!("Fetch user"))
|
.instrument(info_span!("Fetch user"))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let primary_email = match (
|
||||||
|
res.user_email_id,
|
||||||
|
res.user_email,
|
||||||
|
res.user_email_created_at,
|
||||||
|
res.user_email_confirmed_at,
|
||||||
|
) {
|
||||||
|
(Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail {
|
||||||
|
data: id,
|
||||||
|
email,
|
||||||
|
created_at,
|
||||||
|
confirmed_at,
|
||||||
|
}),
|
||||||
|
(None, None, None, None) => None,
|
||||||
|
_ => return Err(DatabaseInconsistencyError.into()),
|
||||||
|
};
|
||||||
|
|
||||||
Ok(User {
|
Ok(User {
|
||||||
data: res.id,
|
data: res.user_id,
|
||||||
username: res.username,
|
username: res.user_username,
|
||||||
sub: format!("fake-sub-{}", res.id),
|
sub: format!("fake-sub-{}", res.user_id),
|
||||||
|
primary_email,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct UserEmailLookup {
|
||||||
|
user_email_id: i64,
|
||||||
|
user_email: String,
|
||||||
|
user_email_created_at: DateTime<Utc>,
|
||||||
|
user_email_confirmed_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UserEmailLookup> for UserEmail<PostgresqlBackend> {
|
||||||
|
fn from(e: UserEmailLookup) -> UserEmail<PostgresqlBackend> {
|
||||||
|
UserEmail {
|
||||||
|
data: e.user_email_id,
|
||||||
|
email: e.user_email,
|
||||||
|
created_at: e.user_email_created_at,
|
||||||
|
confirmed_at: e.user_email_confirmed_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all, fields(user.id = user.data, %user.username))]
|
||||||
|
pub async fn get_user_emails(
|
||||||
|
executor: impl PgExecutor<'_>,
|
||||||
|
user: &User<PostgresqlBackend>,
|
||||||
|
) -> Result<Vec<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
|
||||||
|
|
||||||
|
ORDER BY ue.email ASC
|
||||||
|
"#,
|
||||||
|
user.data,
|
||||||
|
)
|
||||||
|
.fetch_all(executor)
|
||||||
|
.instrument(info_span!("Fetch user emails"))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(res.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
@ -14,7 +14,9 @@
|
|||||||
|
|
||||||
//! Contexts used in templates
|
//! Contexts used in templates
|
||||||
|
|
||||||
use mas_data_model::{errors::ErroredForm, AuthorizationGrant, BrowserSession, StorageBackend};
|
use mas_data_model::{
|
||||||
|
errors::ErroredForm, AuthorizationGrant, BrowserSession, StorageBackend, UserEmail,
|
||||||
|
};
|
||||||
use oauth2_types::errors::OAuth2Error;
|
use oauth2_types::errors::OAuth2Error;
|
||||||
use serde::{ser::SerializeStruct, Serialize};
|
use serde::{ser::SerializeStruct, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@ -386,12 +388,19 @@ pub struct ReauthContext {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct AccountContext {
|
pub struct AccountContext {
|
||||||
active_sessions: usize,
|
active_sessions: usize,
|
||||||
|
emails: Vec<UserEmail<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AccountContext {
|
impl AccountContext {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(active_sessions: usize) -> Self {
|
pub fn new<T>(active_sessions: usize, emails: Vec<T>) -> Self
|
||||||
Self { active_sessions }
|
where
|
||||||
|
T: Into<UserEmail<()>>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
active_sessions,
|
||||||
|
emails: emails.into_iter().map(Into::into).collect(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -400,7 +409,8 @@ impl TemplateContext for AccountContext {
|
|||||||
where
|
where
|
||||||
Self: Sized,
|
Self: Sized,
|
||||||
{
|
{
|
||||||
vec![Self::new(5)]
|
let emails: Vec<UserEmail<()>> = UserEmail::samples();
|
||||||
|
vec![Self::new(5, emails)]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +26,10 @@ limitations under the License.
|
|||||||
<div>{{ current_session.user.sub }}</div>
|
<div>{{ current_session.user.sub }}</div>
|
||||||
<div class="font-bold">Active sessions</div>
|
<div class="font-bold">Active sessions</div>
|
||||||
<div>{{ active_sessions }}</div>
|
<div>{{ active_sessions }}</div>
|
||||||
|
{% if current_session.user.primary_email %}
|
||||||
|
<div class="font-bold">Primary email</div>
|
||||||
|
<div>{{ current_session.user.primary_email.email }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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">Change my password</h2>
|
<h2 class="text-xl font-bold xl:col-span-2">Change my password</h2>
|
||||||
@ -49,5 +53,12 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
{{ button::link_ghost(text="Revalidate", href="/reauth", class="col-span-2 place-self-end") }}
|
{{ button::link_ghost(text="Revalidate", href="/reauth", class="col-span-2 place-self-end") }}
|
||||||
</div>
|
</div>
|
||||||
|
<div 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">
|
||||||
|
<h2 class="text-xl font-bold xl:col-span-2">Emails</h2>
|
||||||
|
{% for email in emails %}
|
||||||
|
<div class="font-bold">{{ email.email }}</div>
|
||||||
|
<div>{% if email.confirmed_at %}Confirmed{% else %}Unconfirmed{% endif %}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
Reference in New Issue
Block a user