You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-07-29 22:01:14 +03:00
Implement a basic "my account" page with password change
This commit is contained in:
@ -589,6 +589,26 @@
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"647a2a5bbde39d0ed3931d0287b468bc7dedf6171e1dc6171a5d9f079b9ed0fa": {
|
||||
"query": "\n SELECT up.hashed_password\n FROM user_passwords up\n WHERE up.user_id = $1\n ORDER BY up.created_at DESC\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hashed_password",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"6765e725d31a1490ddee3f28e32dea41abdd9acefb1edd9a7b4e6790ec131173": {
|
||||
"query": "\n SELECT\n rt.id AS refresh_token_id,\n rt.token AS refresh_token,\n rt.created_at AS refresh_token_created_at,\n at.id AS \"access_token_id?\",\n at.token AS \"access_token?\",\n at.expires_after AS \"access_token_expires_after?\",\n at.created_at AS \"access_token_created_at?\",\n os.id AS \"session_id!\",\n os.client_id AS \"client_id!\",\n os.scope AS \"scope!\",\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 FROM oauth2_refresh_tokens rt\n LEFT JOIN oauth2_access_tokens at\n ON at.id = rt.oauth2_access_token_id\n INNER JOIN oauth2_sessions os\n ON os.id = rt.oauth2_session_id\n INNER JOIN user_sessions us\n ON us.id = os.user_session_id\n INNER 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\n WHERE rt.token = $1\n AND rt.next_token_id IS NULL\n AND us.active\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
@ -913,14 +933,14 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"fe1fcf14de164f06a8fa1a0512ff955beaf83df0eebc4d63ea28d837613f8fed": {
|
||||
"query": "\n SELECT up.hashed_password\n FROM user_sessions s\n INNER JOIN user_passwords up\n ON up.id = s.user_id \n WHERE s.id = $1\n ORDER BY up.created_at DESC\n LIMIT 1\n ",
|
||||
"e5cd99bdaf9c678fc659431fecc5d76b25bb08b781fd17e50eda82ea3aa8cea8": {
|
||||
"query": "\n SELECT COUNT(*) as \"count!\"\n FROM user_sessions s\n WHERE s.user_id = $1 AND s.active\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hashed_password",
|
||||
"type_info": "Text"
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@ -929,7 +949,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
null
|
||||
]
|
||||
}
|
||||
}
|
||||
|
133
crates/core/src/handlers/views/account.rs
Normal file
133
crates/core/src/handlers/views/account.rs
Normal file
@ -0,0 +1,133 @@
|
||||
// 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.
|
||||
|
||||
use argon2::Argon2;
|
||||
use mas_config::{CookiesConfig, CsrfConfig};
|
||||
use mas_data_model::BrowserSession;
|
||||
use mas_templates::{AccountContext, TemplateContext, Templates};
|
||||
use serde::Deserialize;
|
||||
use sqlx::{pool::PoolConnection, PgExecutor, PgPool, Postgres, Transaction};
|
||||
use warp::{reply::html, Filter, Rejection, Reply};
|
||||
|
||||
use crate::{
|
||||
errors::WrapError,
|
||||
filters::{
|
||||
cookies::{encrypted_cookie_saver, EncryptedCookieSaver},
|
||||
csrf::{protected_form, updated_csrf_token},
|
||||
database::{connection, transaction},
|
||||
session::session,
|
||||
with_templates, CsrfToken,
|
||||
},
|
||||
storage::{
|
||||
user::{authenticate_session, count_active_sessions, set_password},
|
||||
PostgresqlBackend,
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) fn filter(
|
||||
pool: &PgPool,
|
||||
templates: &Templates,
|
||||
csrf_config: &CsrfConfig,
|
||||
cookies_config: &CookiesConfig,
|
||||
) -> impl Filter<Extract = (impl Reply,), Error = Rejection> + Clone + Send + Sync + 'static {
|
||||
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)
|
||||
.with(warp::filters::trace::trace(|_info| {
|
||||
tracing::info_span!("GET /account")
|
||||
}));
|
||||
|
||||
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)
|
||||
.with(warp::filters::trace::trace(|_info| {
|
||||
tracing::info_span!("POST /account")
|
||||
}));
|
||||
|
||||
let filter = warp::get().and(get).or(warp::post().and(post));
|
||||
|
||||
warp::path!("account").and(filter)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Form {
|
||||
current_password: String,
|
||||
new_password: String,
|
||||
new_password_confirm: 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 active_sessions = count_active_sessions(executor, &session.user)
|
||||
.await
|
||||
.wrap_error()?;
|
||||
let ctx = AccountContext::new(active_sessions)
|
||||
.with_session(session)
|
||||
.with_csrf(csrf_token.form_value());
|
||||
|
||||
let content = templates.render_account(&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> {
|
||||
authenticate_session(&mut txn, &mut session, form.current_password)
|
||||
.await
|
||||
.wrap_error()?;
|
||||
|
||||
// TODO: display nice form errors
|
||||
if form.new_password != form.new_password_confirm {
|
||||
return Err(anyhow::anyhow!("password mismatch")).wrap_error();
|
||||
}
|
||||
|
||||
let phf = Argon2::default();
|
||||
set_password(&mut txn, phf, &session.user, &form.new_password)
|
||||
.await
|
||||
.wrap_error()?;
|
||||
|
||||
let reply = render(templates, cookie_saver, csrf_token, session, &mut txn).await?;
|
||||
|
||||
txn.commit().await.wrap_error()?;
|
||||
|
||||
Ok(reply)
|
||||
}
|
@ -17,6 +17,7 @@ use mas_templates::Templates;
|
||||
use sqlx::PgPool;
|
||||
use warp::{filters::BoxedFilter, Filter, Reply};
|
||||
|
||||
mod account;
|
||||
mod index;
|
||||
mod login;
|
||||
mod logout;
|
||||
@ -25,8 +26,8 @@ mod register;
|
||||
mod shared;
|
||||
|
||||
use self::{
|
||||
index::filter as index, login::filter as login, logout::filter as logout,
|
||||
reauth::filter as reauth, register::filter as register,
|
||||
account::filter as account, index::filter as index, login::filter as login,
|
||||
logout::filter as logout, reauth::filter as reauth, register::filter as register,
|
||||
};
|
||||
pub(crate) use self::{
|
||||
login::LoginRequest, reauth::ReauthRequest, register::RegisterRequest, shared::PostAuthAction,
|
||||
@ -40,6 +41,7 @@ pub(super) fn filter(
|
||||
cookies_config: &CookiesConfig,
|
||||
) -> BoxedFilter<(impl Reply,)> {
|
||||
index(pool, templates, oauth2_config, csrf_config, cookies_config)
|
||||
.or(account(pool, templates, csrf_config, cookies_config))
|
||||
.or(login(pool, templates, csrf_config, cookies_config))
|
||||
.or(register(pool, templates, csrf_config, cookies_config))
|
||||
.or(logout(pool, cookies_config))
|
||||
|
@ -137,13 +137,13 @@ async fn get(
|
||||
}
|
||||
|
||||
async fn post(
|
||||
session: BrowserSession<PostgresqlBackend>,
|
||||
mut session: BrowserSession<PostgresqlBackend>,
|
||||
mut txn: Transaction<'_, Postgres>,
|
||||
form: ReauthForm,
|
||||
query: ReauthRequest<PostgresqlBackend>,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
// TODO: recover from errors here
|
||||
authenticate_session(&mut txn, &session, form.password)
|
||||
authenticate_session(&mut txn, &mut session, form.password)
|
||||
.await
|
||||
.wrap_error()?;
|
||||
txn.commit().await.wrap_error()?;
|
||||
|
@ -65,6 +65,7 @@ impl HtmlError for LoginError {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(conn, password))]
|
||||
pub async fn login(
|
||||
conn: impl Acquire<'_, Database = Postgres>,
|
||||
username: &str,
|
||||
@ -85,20 +86,19 @@ pub async fn login(
|
||||
})?;
|
||||
|
||||
let mut session = start_session(&mut txn, user).await?;
|
||||
session.last_authentication = Some(
|
||||
authenticate_session(&mut txn, &session, password)
|
||||
.await
|
||||
.map_err(|source| {
|
||||
if matches!(source, AuthenticationError::Password { .. }) {
|
||||
LoginError::Authentication {
|
||||
username: username.to_string(),
|
||||
source,
|
||||
}
|
||||
} else {
|
||||
LoginError::Other(source.into())
|
||||
authenticate_session(&mut txn, &mut session, password)
|
||||
.await
|
||||
.map_err(|source| {
|
||||
if matches!(source, AuthenticationError::Password { .. }) {
|
||||
LoginError::Authentication {
|
||||
username: username.to_string(),
|
||||
source,
|
||||
}
|
||||
})?,
|
||||
);
|
||||
} else {
|
||||
LoginError::Other(source.into())
|
||||
}
|
||||
})?;
|
||||
|
||||
txn.commit().await.context("could not commit transaction")?;
|
||||
Ok(session)
|
||||
}
|
||||
@ -218,6 +218,26 @@ pub async fn start_session(
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(user.id = user.data))]
|
||||
pub async fn count_active_sessions(
|
||||
executor: impl PgExecutor<'_>,
|
||||
user: &User<PostgresqlBackend>,
|
||||
) -> Result<usize, anyhow::Error> {
|
||||
let res = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT COUNT(*) as "count!"
|
||||
FROM user_sessions s
|
||||
WHERE s.user_id = $1 AND s.active
|
||||
"#,
|
||||
user.data,
|
||||
)
|
||||
.fetch_one(executor)
|
||||
.await?
|
||||
.try_into()?;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthenticationError {
|
||||
#[error("could not verify password")]
|
||||
@ -233,25 +253,25 @@ pub enum AuthenticationError {
|
||||
Internal(#[from] tokio::task::JoinError),
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(session.id = session.data, user.id = session.user.data))]
|
||||
pub async fn authenticate_session(
|
||||
txn: &mut Transaction<'_, Postgres>,
|
||||
session: &BrowserSession<PostgresqlBackend>,
|
||||
session: &mut BrowserSession<PostgresqlBackend>,
|
||||
password: String,
|
||||
) -> Result<Authentication<PostgresqlBackend>, AuthenticationError> {
|
||||
) -> Result<(), AuthenticationError> {
|
||||
// First, fetch the hashed password from the user associated with that session
|
||||
let hashed_password: String = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT up.hashed_password
|
||||
FROM user_sessions s
|
||||
INNER JOIN user_passwords up
|
||||
ON up.id = s.user_id
|
||||
WHERE s.id = $1
|
||||
FROM user_passwords up
|
||||
WHERE up.user_id = $1
|
||||
ORDER BY up.created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
session.data,
|
||||
session.user.data,
|
||||
)
|
||||
.fetch_one(txn.borrow_mut())
|
||||
.instrument(tracing::info_span!("Lookup hashed password"))
|
||||
.await
|
||||
.map_err(AuthenticationError::Fetch)?;
|
||||
|
||||
@ -264,6 +284,7 @@ pub async fn authenticate_session(
|
||||
.verify_password(&[&context], &password)
|
||||
.map_err(AuthenticationError::Password)
|
||||
})
|
||||
.instrument(tracing::info_span!("Verify hashed password"))
|
||||
.await??;
|
||||
|
||||
// That went well, let's insert the auth info
|
||||
@ -277,15 +298,19 @@ pub async fn authenticate_session(
|
||||
session.data,
|
||||
)
|
||||
.fetch_one(txn.borrow_mut())
|
||||
.instrument(tracing::info_span!("Save authentication"))
|
||||
.await
|
||||
.map_err(AuthenticationError::Save)?;
|
||||
|
||||
Ok(Authentication {
|
||||
session.last_authentication = Some(Authentication {
|
||||
data: res.id,
|
||||
created_at: res.created_at,
|
||||
})
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(txn, phf, password))]
|
||||
pub async fn register_user(
|
||||
txn: &mut Transaction<'_, Postgres>,
|
||||
phf: impl PasswordHasher,
|
||||
@ -305,6 +330,24 @@ pub async fn register_user(
|
||||
.await
|
||||
.context("could not insert user")?;
|
||||
|
||||
let user = User {
|
||||
data: id,
|
||||
username: username.to_string(),
|
||||
sub: format!("fake-sub-{}", id),
|
||||
};
|
||||
|
||||
set_password(txn.borrow_mut(), phf, &user, password).await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(user.id = user.data))]
|
||||
pub async fn set_password(
|
||||
executor: impl PgExecutor<'_>,
|
||||
phf: impl PasswordHasher,
|
||||
user: &User<PostgresqlBackend>,
|
||||
password: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hashed_password = PasswordHash::generate(phf, password, salt.as_str())?;
|
||||
|
||||
@ -313,21 +356,18 @@ pub async fn register_user(
|
||||
INSERT INTO user_passwords (user_id, hashed_password)
|
||||
VALUES ($1, $2)
|
||||
"#,
|
||||
id,
|
||||
user.data,
|
||||
hashed_password.to_string(),
|
||||
)
|
||||
.execute(txn.borrow_mut())
|
||||
.execute(executor)
|
||||
.instrument(info_span!("Save user credentials"))
|
||||
.await
|
||||
.context("could not insert user password")?;
|
||||
|
||||
Ok(User {
|
||||
data: id,
|
||||
username: username.to_string(),
|
||||
sub: format!("fake-sub-{}", id),
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(session.id = session.data))]
|
||||
pub async fn end_session(
|
||||
executor: impl PgExecutor<'_>,
|
||||
session: &BrowserSession<PostgresqlBackend>,
|
||||
@ -348,6 +388,7 @@ pub async fn end_session(
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(executor))]
|
||||
pub async fn lookup_user_by_username(
|
||||
executor: impl PgExecutor<'_>,
|
||||
username: &str,
|
||||
|
@ -382,6 +382,28 @@ pub struct ReauthContext {
|
||||
next: Option<PostAuthContext>,
|
||||
}
|
||||
|
||||
/// Context used by the `account.html` template
|
||||
#[derive(Serialize)]
|
||||
pub struct AccountContext {
|
||||
active_sessions: usize,
|
||||
}
|
||||
|
||||
impl AccountContext {
|
||||
#[must_use]
|
||||
pub fn new(active_sessions: usize) -> Self {
|
||||
Self { active_sessions }
|
||||
}
|
||||
}
|
||||
|
||||
impl TemplateContext for AccountContext {
|
||||
fn sample() -> Vec<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
vec![Self::new(5)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Context used by the `form_post.html` template
|
||||
#[derive(Serialize)]
|
||||
pub struct FormPostContext<T> {
|
||||
|
@ -47,9 +47,9 @@ mod functions;
|
||||
mod macros;
|
||||
|
||||
pub use self::context::{
|
||||
EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField,
|
||||
PostAuthContext, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField,
|
||||
TemplateContext, WithCsrf, WithOptionalSession, WithSession,
|
||||
AccountContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext,
|
||||
LoginFormField, PostAuthContext, ReauthContext, ReauthFormField, RegisterContext,
|
||||
RegisterFormField, TemplateContext, WithCsrf, WithOptionalSession, WithSession,
|
||||
};
|
||||
|
||||
/// Wrapper around [`tera::Tera`] helping rendering the various templates
|
||||
@ -293,6 +293,9 @@ register_templates! {
|
||||
/// Render the home page
|
||||
pub fn render_index(WithCsrf<WithOptionalSession<IndexContext>>) { "index.html" }
|
||||
|
||||
/// Render the account management page
|
||||
pub fn render_account(WithCsrf<WithSession<AccountContext>>) { "account.html" }
|
||||
|
||||
/// Render the re-authentication form
|
||||
pub fn render_reauth(WithCsrf<WithSession<ReauthContext>>) { "reauth.html" }
|
||||
|
||||
@ -310,6 +313,7 @@ impl Templates {
|
||||
check::render_login(self).await?;
|
||||
check::render_register(self).await?;
|
||||
check::render_index(self).await?;
|
||||
check::render_account(self).await?;
|
||||
check::render_reauth(self).await?;
|
||||
check::render_form_post::<EmptyContext>(self).await?;
|
||||
check::render_error(self).await?;
|
||||
|
53
crates/templates/src/res/account.html
Normal file
53
crates/templates/src/res/account.html
Normal file
@ -0,0 +1,53 @@
|
||||
{#
|
||||
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.
|
||||
#}
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="container mx-auto grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 p-2">
|
||||
<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">
|
||||
<h1 class="text-2xl font-bold xl:col-span-2">Manage my account</h1>
|
||||
<div class="font-bold">Your username</div>
|
||||
<div>{{ current_session.user.username }}</div>
|
||||
<div class="font-bold">Unique identifier</div>
|
||||
<div>{{ current_session.user.sub }}</div>
|
||||
<div class="font-bold">Active sessions</div>
|
||||
<div>{{ active_sessions }}</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">
|
||||
<h2 class="text-xl font-bold xl:col-span-2">Change my password</h2>
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
||||
{{ 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") }}
|
||||
</form>
|
||||
<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">Current session</h2>
|
||||
<div class="font-bold">Started at</div>
|
||||
<div>{{ current_session.created_at | date(format="%Y-%m-%d %H:%M:%S") }}</div>
|
||||
<div class="font-bold">Last authentication</div>
|
||||
<div>
|
||||
{% if current_session.last_authentication %}
|
||||
{{ current_session.last_authentication.created_at | date(format="%Y-%m-%d %H:%M:%S") }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ button::link_ghost(text="Revalidate", href="/reauth", class="col-span-2 place-self-end") }}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock content %}
|
@ -39,6 +39,8 @@ limitations under the License.
|
||||
Logged in as <span class="font-bold">{{ current_session.user.username }}</span>.
|
||||
</div>
|
||||
|
||||
{{ button::link(text="My account", href="/account") }}
|
||||
|
||||
<form method="POST" action="/logout">
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
||||
{{ button::button_ghost(text="Log out", name="logout", type="submit") }}
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
#}
|
||||
|
||||
{% macro input(label, name, type="text", errors=false) %}
|
||||
{% macro input(label, name, type="text", errors=false, class="") %}
|
||||
{% if errors is not empty %}
|
||||
{% set border_color = "border-alert" %}
|
||||
{% set text_color = "text-alert" %}
|
||||
@ -22,7 +22,7 @@ limitations under the License.
|
||||
{% set border_color = "border-grey-50 dark:border-grey-450" %}
|
||||
{% set text_color = "text-black-800 dark:text-grey-300" %}
|
||||
{% endif %}
|
||||
<label class="flex flex-col block">
|
||||
<label class="flex flex-col block {{ class }}">
|
||||
<div class="mx-2 -mb-3 -mt-2 leading-5 px-1 z-10 self-start bg-white dark:bg-black-900 border-white border-1 dark:border-2 dark:border-black-900 rounded-full text-sm {{ text_color }}">{{ label }}</div>
|
||||
<input name="{{ name }}" class="z-0 px-3 py-2 bg-white dark:bg-black-900 rounded-lg {{ border_color }} border-1 dark:border-2 focus:border-accent focus:ring-0 focus:outline-0" type="{{ type }}" />
|
||||
|
||||
|
Reference in New Issue
Block a user