mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-07-20 22:11:03 +00:00
Better error pages when a user is deactivated or locked
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
use mas_data_model::BrowserSession;
|
||||
use mas_storage::{RepositoryAccess, user::BrowserSessionRepository};
|
||||
use mas_storage::RepositoryAccess;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ulid::Ulid;
|
||||
|
||||
@@ -33,13 +33,12 @@ impl SessionInfo {
|
||||
self
|
||||
}
|
||||
|
||||
/// Load the [`BrowserSession`] from database
|
||||
/// Load the active [`BrowserSession`] from database
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the session is not found or if the session is not
|
||||
/// active anymore
|
||||
pub async fn load_session<E>(
|
||||
/// Returns an error if the underlying repository fails to load the session.
|
||||
pub async fn load_active_session<E>(
|
||||
&self,
|
||||
repo: &mut impl RepositoryAccess<Error = E>,
|
||||
) -> Result<Option<BrowserSession>, E> {
|
||||
@@ -56,6 +55,12 @@ impl SessionInfo {
|
||||
|
||||
Ok(maybe_session)
|
||||
}
|
||||
|
||||
/// Get the current session ID, if any
|
||||
#[must_use]
|
||||
pub fn current_session_id(&self) -> Option<Ulid> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
pub trait SessionInfoExt {
|
||||
|
||||
@@ -13,7 +13,7 @@ use axum::{
|
||||
};
|
||||
use chrono::Duration;
|
||||
use mas_axum_utils::{
|
||||
FancyError, SessionInfoExt,
|
||||
FancyError,
|
||||
cookies::CookieJar,
|
||||
csrf::{CsrfExt, ProtectedForm},
|
||||
};
|
||||
@@ -28,7 +28,10 @@ use mas_templates::{CompatSsoContext, ErrorContext, TemplateContext, Templates};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::PreferredLanguage;
|
||||
use crate::{
|
||||
PreferredLanguage,
|
||||
session::{SessionOrFallback, load_session_or_fallback},
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AllParams<'s> {
|
||||
@@ -61,10 +64,20 @@ pub async fn get(
|
||||
Path(id): Path<Ulid>,
|
||||
Query(params): Query<Params>,
|
||||
) -> Result<Response, FancyError> {
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
let Some(session) = maybe_session else {
|
||||
// If there is no session, redirect to the login or register screen
|
||||
@@ -126,10 +139,20 @@ pub async fn post(
|
||||
Query(params): Query<Params>,
|
||||
Form(form): Form<ProtectedForm<()>>,
|
||||
) -> Result<Response, FancyError> {
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
cookie_jar.verify_form(&clock, form)?;
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
cookie_jar.verify_form(&clock, form)?;
|
||||
|
||||
let Some(session) = maybe_session else {
|
||||
// If there is no session, redirect to the login or register screen
|
||||
|
||||
@@ -288,7 +288,7 @@ async fn get_requester(
|
||||
|
||||
RequestingEntity::OAuth2Session(Box::new((session, user)))
|
||||
} else {
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let maybe_session = session_info.load_active_session(&mut repo).await?;
|
||||
|
||||
if let Some(session) = maybe_session.as_ref() {
|
||||
activity_tracker
|
||||
|
||||
@@ -64,6 +64,7 @@ mod activity_tracker;
|
||||
mod captcha;
|
||||
mod preferred_language;
|
||||
mod rate_limit;
|
||||
mod session;
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ pub(crate) async fn get(
|
||||
) -> Result<Response, RouteError> {
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let maybe_session = session_info.load_active_session(&mut repo).await?;
|
||||
|
||||
let user_agent = user_agent.map(|TypedHeader(ua)| ua.to_string());
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ pub(crate) async fn get(
|
||||
let callback_destination = callback_destination.clone();
|
||||
let locale = locale.clone();
|
||||
async move {
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let maybe_session = session_info.load_active_session(&mut repo).await?;
|
||||
let prompt = params.auth.prompt.as_deref().unwrap_or_default();
|
||||
|
||||
// Check if the request/request_uri/registration params are used. If so, reply
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
@@ -11,7 +11,6 @@ use axum::{
|
||||
use axum_extra::TypedHeader;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::{
|
||||
SessionInfoExt,
|
||||
cookies::CookieJar,
|
||||
csrf::{CsrfExt, ProtectedForm},
|
||||
sentry::SentryEventID,
|
||||
@@ -27,7 +26,10 @@ use mas_templates::{ConsentContext, PolicyViolationContext, TemplateContext, Tem
|
||||
use thiserror::Error;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{BoundActivityTracker, PreferredLanguage, impl_from_error_for_route};
|
||||
use crate::{
|
||||
BoundActivityTracker, PreferredLanguage, impl_from_error_for_route,
|
||||
session::{SessionOrFallback, load_session_or_fallback},
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RouteError {
|
||||
@@ -54,6 +56,7 @@ impl_from_error_for_route!(mas_templates::TemplateError);
|
||||
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||
impl_from_error_for_route!(mas_policy::LoadError);
|
||||
impl_from_error_for_route!(mas_policy::EvaluationError);
|
||||
impl_from_error_for_route!(crate::session::SessionLoadError);
|
||||
|
||||
impl IntoResponse for RouteError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
@@ -85,9 +88,18 @@ pub(crate) async fn get(
|
||||
cookie_jar: CookieJar,
|
||||
Path(grant_id): Path<Ulid>,
|
||||
) -> Result<Response, RouteError> {
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
|
||||
let user_agent = user_agent.map(|ua| ua.to_string());
|
||||
|
||||
@@ -107,48 +119,48 @@ pub(crate) async fn get(
|
||||
return Err(RouteError::GrantNotPending);
|
||||
}
|
||||
|
||||
if let Some(session) = maybe_session {
|
||||
activity_tracker
|
||||
.record_browser_session(&clock, &session)
|
||||
.await;
|
||||
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
let res = policy
|
||||
.evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
|
||||
user: Some(&session.user),
|
||||
client: &client,
|
||||
scope: &grant.scope,
|
||||
grant_type: mas_policy::GrantType::AuthorizationCode,
|
||||
requester: mas_policy::Requester {
|
||||
ip_address: activity_tracker.ip(),
|
||||
user_agent,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
if res.valid() {
|
||||
let ctx = ConsentContext::new(grant, client)
|
||||
.with_session(session)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale);
|
||||
|
||||
let content = templates.render_consent(&ctx)?;
|
||||
|
||||
Ok((cookie_jar, Html(content)).into_response())
|
||||
} else {
|
||||
let ctx = PolicyViolationContext::for_authorization_grant(grant, client)
|
||||
.with_session(session)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale);
|
||||
|
||||
let content = templates.render_policy_violation(&ctx)?;
|
||||
|
||||
Ok((cookie_jar, Html(content)).into_response())
|
||||
}
|
||||
} else {
|
||||
let Some(session) = maybe_session else {
|
||||
let login = mas_router::Login::and_continue_grant(grant_id);
|
||||
Ok((cookie_jar, url_builder.redirect(&login)).into_response())
|
||||
return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
|
||||
};
|
||||
|
||||
activity_tracker
|
||||
.record_browser_session(&clock, &session)
|
||||
.await;
|
||||
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
let res = policy
|
||||
.evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
|
||||
user: Some(&session.user),
|
||||
client: &client,
|
||||
scope: &grant.scope,
|
||||
grant_type: mas_policy::GrantType::AuthorizationCode,
|
||||
requester: mas_policy::Requester {
|
||||
ip_address: activity_tracker.ip(),
|
||||
user_agent,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
if res.valid() {
|
||||
let ctx = ConsentContext::new(grant, client)
|
||||
.with_session(session)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale);
|
||||
|
||||
let content = templates.render_consent(&ctx)?;
|
||||
|
||||
Ok((cookie_jar, Html(content)).into_response())
|
||||
} else {
|
||||
let ctx = PolicyViolationContext::for_authorization_grant(grant, client)
|
||||
.with_session(session)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale);
|
||||
|
||||
let content = templates.render_policy_violation(&ctx)?;
|
||||
|
||||
Ok((cookie_jar, Html(content)).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +173,8 @@ pub(crate) async fn get(
|
||||
pub(crate) async fn post(
|
||||
mut rng: BoxRng,
|
||||
clock: BoxClock,
|
||||
PreferredLanguage(locale): PreferredLanguage,
|
||||
State(templates): State<Templates>,
|
||||
mut policy: Policy,
|
||||
mut repo: BoxRepository,
|
||||
activity_tracker: BoundActivityTracker,
|
||||
@@ -172,9 +186,18 @@ pub(crate) async fn post(
|
||||
) -> Result<Response, RouteError> {
|
||||
cookie_jar.verify_form(&clock, form)?;
|
||||
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
|
||||
let user_agent = user_agent.map(|ua| ua.to_string());
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use axum::{
|
||||
};
|
||||
use axum_extra::TypedHeader;
|
||||
use mas_axum_utils::{
|
||||
FancyError, SessionInfoExt,
|
||||
FancyError,
|
||||
cookies::CookieJar,
|
||||
csrf::{CsrfExt, ProtectedForm},
|
||||
};
|
||||
@@ -24,7 +24,10 @@ use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{BoundActivityTracker, PreferredLanguage};
|
||||
use crate::{
|
||||
BoundActivityTracker, PreferredLanguage,
|
||||
session::{SessionOrFallback, load_session_or_fallback},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -51,10 +54,20 @@ pub(crate) async fn get(
|
||||
cookie_jar: CookieJar,
|
||||
Path(grant_id): Path<Ulid>,
|
||||
) -> Result<Response, FancyError> {
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
let user_agent = user_agent.map(|ua| ua.to_string());
|
||||
|
||||
@@ -137,12 +150,21 @@ pub(crate) async fn post(
|
||||
Path(grant_id): Path<Ulid>,
|
||||
Form(form): Form<ProtectedForm<ConsentForm>>,
|
||||
) -> Result<Response, FancyError> {
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let form = cookie_jar.verify_form(&clock, form)?;
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
|
||||
let user_agent = user_agent.map(|TypedHeader(ua)| ua.to_string());
|
||||
|
||||
let Some(session) = maybe_session else {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
//! Utilities for showing proposer HTML fallbacks when the user is logged out,
|
||||
//! locked or deactivated
|
||||
|
||||
use axum::response::{Html, IntoResponse as _, Response};
|
||||
use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, csrf::CsrfExt};
|
||||
use mas_data_model::BrowserSession;
|
||||
use mas_i18n::DataLocale;
|
||||
use mas_storage::{BoxRepository, Clock, RepositoryError};
|
||||
use mas_templates::{AccountInactiveContext, TemplateContext, Templates};
|
||||
use rand::RngCore;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error(transparent)]
|
||||
pub enum SessionLoadError {
|
||||
Template(#[from] mas_templates::TemplateError),
|
||||
Repository(#[from] RepositoryError),
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum SessionOrFallback {
|
||||
MaybeSession {
|
||||
cookie_jar: CookieJar,
|
||||
maybe_session: Option<BrowserSession>,
|
||||
},
|
||||
Fallback {
|
||||
response: Response,
|
||||
},
|
||||
}
|
||||
|
||||
/// Load a session from the cookie jar, or fall back to an HTML error page if
|
||||
/// the account is locked, deactivated or logged out
|
||||
pub async fn load_session_or_fallback(
|
||||
cookie_jar: CookieJar,
|
||||
clock: &impl Clock,
|
||||
rng: impl RngCore,
|
||||
templates: &Templates,
|
||||
locale: &DataLocale,
|
||||
repo: &mut BoxRepository,
|
||||
) -> Result<SessionOrFallback, SessionLoadError> {
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let Some(session_id) = session_info.current_session_id() else {
|
||||
return Ok(SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session: None,
|
||||
});
|
||||
};
|
||||
|
||||
let Some(session) = repo.browser_session().lookup(session_id).await? else {
|
||||
// We looked up the session, but it was not found. Still update the cookie
|
||||
let session_info = session_info.mark_session_ended();
|
||||
let cookie_jar = cookie_jar.update_session_info(&session_info);
|
||||
return Ok(SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session: None,
|
||||
});
|
||||
};
|
||||
|
||||
if session.user.deactivated_at.is_some() {
|
||||
// The account is deactivated, show the 'account deactivated' fallback
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng);
|
||||
let ctx = AccountInactiveContext::new(session.user)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale.clone());
|
||||
let fallback = templates.render_account_deactivated(&ctx)?;
|
||||
let response = (cookie_jar, Html(fallback)).into_response();
|
||||
return Ok(SessionOrFallback::Fallback { response });
|
||||
}
|
||||
|
||||
if session.user.locked_at.is_some() {
|
||||
// The account is locked, show the 'account locked' fallback
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng);
|
||||
let ctx = AccountInactiveContext::new(session.user)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale.clone());
|
||||
let fallback = templates.render_account_locked(&ctx)?;
|
||||
let response = (cookie_jar, Html(fallback)).into_response();
|
||||
return Ok(SessionOrFallback::Fallback { response });
|
||||
}
|
||||
|
||||
if session.finished_at.is_some() {
|
||||
// The session has finished, but the browser still has the cookie. This is
|
||||
// likely a 'remote' logout, triggered either by an admin or from the
|
||||
// user-management UI. In this case, we show the 'account logged out'
|
||||
// fallback.
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng);
|
||||
let ctx = AccountInactiveContext::new(session.user)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale.clone());
|
||||
let fallback = templates.render_account_logged_out(&ctx)?;
|
||||
let response = (cookie_jar, Html(fallback)).into_response();
|
||||
return Ok(SessionOrFallback::Fallback { response });
|
||||
}
|
||||
|
||||
Ok(SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session: Some(session),
|
||||
})
|
||||
}
|
||||
@@ -242,7 +242,7 @@ pub(crate) async fn get(
|
||||
|
||||
let (user_session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let (csrf_token, mut cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
let maybe_user_session = user_session_info.load_session(&mut repo).await?;
|
||||
let maybe_user_session = user_session_info.load_active_session(&mut repo).await?;
|
||||
|
||||
let response = match (maybe_user_session, link.user_id) {
|
||||
(Some(session), Some(user_id)) if session.user.id == user_id => {
|
||||
@@ -556,7 +556,7 @@ pub(crate) async fn post(
|
||||
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
let (user_session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let maybe_user_session = user_session_info.load_session(&mut repo).await?;
|
||||
let maybe_user_session = user_session_info.load_active_session(&mut repo).await?;
|
||||
let form_state = form.to_form_state();
|
||||
|
||||
let session = match (maybe_user_session, link.user_id, form) {
|
||||
|
||||
@@ -8,13 +8,16 @@ use axum::{
|
||||
extract::{Query, State},
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
use mas_axum_utils::{FancyError, SessionInfoExt, cookies::CookieJar};
|
||||
use mas_axum_utils::{FancyError, cookies::CookieJar};
|
||||
use mas_router::{PostAuthAction, UrlBuilder};
|
||||
use mas_storage::{BoxClock, BoxRepository};
|
||||
use mas_storage::{BoxClock, BoxRepository, BoxRng};
|
||||
use mas_templates::{AppContext, TemplateContext, Templates};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{BoundActivityTracker, PreferredLanguage};
|
||||
use crate::{
|
||||
BoundActivityTracker, PreferredLanguage,
|
||||
session::{SessionOrFallback, load_session_or_fallback},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Params {
|
||||
@@ -31,13 +34,24 @@ pub async fn get(
|
||||
Query(Params { action }): Query<Params>,
|
||||
mut repo: BoxRepository,
|
||||
clock: BoxClock,
|
||||
mut rng: BoxRng,
|
||||
cookie_jar: CookieJar,
|
||||
) -> Result<impl IntoResponse, FancyError> {
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let session = session_info.load_session(&mut repo).await?;
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
|
||||
// TODO: keep the full path, not just the action
|
||||
let Some(session) = session else {
|
||||
let Some(session) = maybe_session else {
|
||||
return Ok((
|
||||
cookie_jar,
|
||||
url_builder.redirect(&mas_router::Login::and_then(
|
||||
|
||||
@@ -6,14 +6,18 @@
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
response::{Html, IntoResponse},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use mas_axum_utils::{FancyError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt};
|
||||
use mas_axum_utils::{FancyError, cookies::CookieJar, csrf::CsrfExt};
|
||||
use mas_router::UrlBuilder;
|
||||
use mas_storage::{BoxClock, BoxRepository, BoxRng};
|
||||
use mas_templates::{IndexContext, TemplateContext, Templates};
|
||||
|
||||
use crate::{BoundActivityTracker, preferred_language::PreferredLanguage};
|
||||
use crate::{
|
||||
BoundActivityTracker,
|
||||
preferred_language::PreferredLanguage,
|
||||
session::{SessionOrFallback, load_session_or_fallback},
|
||||
};
|
||||
|
||||
#[tracing::instrument(name = "handlers.views.index.get", skip_all, err)]
|
||||
pub async fn get(
|
||||
@@ -25,23 +29,34 @@ pub async fn get(
|
||||
mut repo: BoxRepository,
|
||||
cookie_jar: CookieJar,
|
||||
PreferredLanguage(locale): PreferredLanguage,
|
||||
) -> Result<impl IntoResponse, FancyError> {
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let session = session_info.load_session(&mut repo).await?;
|
||||
) -> Result<Response, FancyError> {
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
|
||||
if let Some(session) = session.as_ref() {
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
if let Some(session) = maybe_session.as_ref() {
|
||||
activity_tracker
|
||||
.record_browser_session(&clock, session)
|
||||
.await;
|
||||
}
|
||||
|
||||
let ctx = IndexContext::new(url_builder.oidc_discovery())
|
||||
.maybe_with_session(session)
|
||||
.maybe_with_session(maybe_session)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale);
|
||||
|
||||
let content = templates.render_index(&ctx)?;
|
||||
|
||||
Ok((cookie_jar, Html(content)))
|
||||
Ok((cookie_jar, Html(content)).into_response())
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ use super::shared::OptionalPostAuthAction;
|
||||
use crate::{
|
||||
BoundActivityTracker, Limiter, PreferredLanguage, RequesterFingerprint, SiteConfig,
|
||||
passwords::PasswordManager,
|
||||
session::{SessionOrFallback, load_session_or_fallback},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
@@ -64,10 +65,20 @@ pub(crate) async fn get(
|
||||
Query(query): Query<OptionalPostAuthAction>,
|
||||
cookie_jar: CookieJar,
|
||||
) -> Result<Response, FancyError> {
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
if let Some(session) = maybe_session {
|
||||
activity_tracker
|
||||
|
||||
@@ -29,21 +29,27 @@ pub(crate) async fn post(
|
||||
) -> Result<impl IntoResponse, FancyError> {
|
||||
let form = cookie_jar.verify_form(&clock, form)?;
|
||||
|
||||
let (session_info, mut cookie_jar) = cookie_jar.session_info();
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
if let Some(session_id) = session_info.current_session_id() {
|
||||
let maybe_session = repo.browser_session().lookup(session_id).await?;
|
||||
if let Some(session) = maybe_session {
|
||||
if session.finished_at.is_none() {
|
||||
activity_tracker
|
||||
.record_browser_session(&clock, &session)
|
||||
.await;
|
||||
|
||||
if let Some(session) = maybe_session {
|
||||
activity_tracker
|
||||
.record_browser_session(&clock, &session)
|
||||
.await;
|
||||
|
||||
repo.browser_session().finish(&clock, session).await?;
|
||||
cookie_jar = cookie_jar.update_session_info(&session_info.mark_session_ended());
|
||||
repo.browser_session().finish(&clock, session).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
// We always want to clear out the session cookie, even if the session was
|
||||
// invalid
|
||||
let cookie_jar = cookie_jar.update_session_info(&session_info.mark_session_ended());
|
||||
|
||||
let destination = if let Some(action) = form {
|
||||
action.go_next(&url_builder)
|
||||
} else {
|
||||
|
||||
@@ -25,7 +25,11 @@ use serde::Deserialize;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::shared::OptionalPostAuthAction;
|
||||
use crate::{BoundActivityTracker, PreferredLanguage, SiteConfig, passwords::PasswordManager};
|
||||
use crate::{
|
||||
BoundActivityTracker, PreferredLanguage, SiteConfig,
|
||||
passwords::PasswordManager,
|
||||
session::{SessionOrFallback, load_session_or_fallback},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct ReauthForm {
|
||||
@@ -52,10 +56,18 @@ pub(crate) async fn get(
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
|
||||
let Some(session) = maybe_session else {
|
||||
// If there is no session, redirect to the login screen, keeping the
|
||||
@@ -64,6 +76,8 @@ pub(crate) async fn get(
|
||||
return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
|
||||
};
|
||||
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
activity_tracker
|
||||
.record_browser_session(&clock, &session)
|
||||
.await;
|
||||
@@ -89,6 +103,8 @@ pub(crate) async fn get(
|
||||
pub(crate) async fn post(
|
||||
mut rng: BoxRng,
|
||||
clock: BoxClock,
|
||||
PreferredLanguage(locale): PreferredLanguage,
|
||||
State(templates): State<Templates>,
|
||||
State(password_manager): State<PasswordManager>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
@@ -104,9 +120,18 @@ pub(crate) async fn post(
|
||||
|
||||
let form = cookie_jar.verify_form(&clock, form)?;
|
||||
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let (cookie_jar, maybe_session) = match load_session_or_fallback(
|
||||
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionOrFallback::MaybeSession {
|
||||
cookie_jar,
|
||||
maybe_session,
|
||||
..
|
||||
} => (cookie_jar, maybe_session),
|
||||
SessionOrFallback::Fallback { response } => return Ok(response),
|
||||
};
|
||||
|
||||
let Some(session) = maybe_session else {
|
||||
// If there is no session, redirect to the login screen, keeping the
|
||||
|
||||
@@ -46,7 +46,7 @@ pub(crate) async fn get(
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let maybe_session = session_info.load_active_session(&mut repo).await?;
|
||||
if maybe_session.is_some() {
|
||||
// TODO: redirect to continue whatever action was going on
|
||||
return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response());
|
||||
@@ -100,7 +100,7 @@ pub(crate) async fn post(
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let maybe_session = session_info.load_active_session(&mut repo).await?;
|
||||
if maybe_session.is_some() {
|
||||
// TODO: redirect to continue whatever action was going on
|
||||
return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response());
|
||||
|
||||
@@ -56,7 +56,7 @@ pub(crate) async fn get(
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let maybe_session = session_info.load_active_session(&mut repo).await?;
|
||||
if maybe_session.is_some() {
|
||||
// TODO: redirect to continue whatever action was going on
|
||||
return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response());
|
||||
@@ -96,7 +96,7 @@ pub(crate) async fn post(
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let maybe_session = session_info.load_active_session(&mut repo).await?;
|
||||
if maybe_session.is_some() {
|
||||
// TODO: redirect to continue whatever action was going on
|
||||
return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response());
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(crate) async fn get(
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let maybe_session = session_info.load_active_session(&mut repo).await?;
|
||||
|
||||
if let Some(session) = maybe_session {
|
||||
activity_tracker
|
||||
|
||||
@@ -81,7 +81,7 @@ pub(crate) async fn get(
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
let maybe_session = session_info.load_active_session(&mut repo).await?;
|
||||
|
||||
if maybe_session.is_some() {
|
||||
let reply = query.action.go_next(&url_builder);
|
||||
|
||||
@@ -1590,6 +1590,33 @@ impl TemplateContext for DeviceConsentContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Context used by the `account/deactivated.html` and `account/locked.html`
|
||||
/// templates
|
||||
#[derive(Serialize)]
|
||||
pub struct AccountInactiveContext {
|
||||
user: User,
|
||||
}
|
||||
|
||||
impl AccountInactiveContext {
|
||||
/// Constructs a new context with an existing linked user
|
||||
#[must_use]
|
||||
pub fn new(user: User) -> Self {
|
||||
Self { user }
|
||||
}
|
||||
}
|
||||
|
||||
impl TemplateContext for AccountInactiveContext {
|
||||
fn sample(now: chrono::DateTime<Utc>, rng: &mut impl Rng) -> Vec<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
User::samples(now, rng)
|
||||
.into_iter()
|
||||
.map(|user| AccountInactiveContext { user })
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Context used by the `form_post.html` template
|
||||
#[derive(Serialize)]
|
||||
pub struct FormPostContext<T> {
|
||||
|
||||
@@ -34,14 +34,14 @@ mod macros;
|
||||
|
||||
pub use self::{
|
||||
context::{
|
||||
ApiDocContext, AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext,
|
||||
DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext, EmailVerificationContext,
|
||||
EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField,
|
||||
NotFoundContext, PasswordRegisterContext, PolicyViolationContext, PostAuthContext,
|
||||
PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryExpiredContext,
|
||||
RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
|
||||
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
|
||||
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
|
||||
AccountInactiveContext, ApiDocContext, AppContext, CompatSsoContext, ConsentContext,
|
||||
DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext,
|
||||
EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
|
||||
LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext,
|
||||
PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext,
|
||||
ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
|
||||
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
|
||||
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
|
||||
RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext,
|
||||
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
|
||||
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
|
||||
@@ -413,6 +413,15 @@ register_templates! {
|
||||
|
||||
/// Render the device code consent page
|
||||
pub fn render_device_consent(WithLanguage<WithCsrf<WithSession<DeviceConsentContext>>>) { "pages/device_consent.html" }
|
||||
|
||||
/// Render the 'account deactivated' page
|
||||
pub fn render_account_deactivated(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/deactivated.html" }
|
||||
|
||||
/// Render the 'account locked' page
|
||||
pub fn render_account_locked(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/locked.html" }
|
||||
|
||||
/// Render the 'account logged out' page
|
||||
pub fn render_account_logged_out(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/logged_out.html" }
|
||||
}
|
||||
|
||||
impl Templates {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{#
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
-#}
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main class="flex flex-col gap-6">
|
||||
<header class="page-heading">
|
||||
<div class="icon invalid">
|
||||
{{ icon.delete() }}
|
||||
</div>
|
||||
|
||||
<div class="header">
|
||||
<h1 class="title">{{ _("mas.account.deactivated.heading") }}</h1>
|
||||
{% set mxid = "@" + user.username + ":" + branding.server_name %}
|
||||
<p class="text">{{ _("mas.account.deactivated.description", mxid=mxid) }}</p>
|
||||
</div>
|
||||
|
||||
{{ logout.button(text=_("action.sign_in"), csrf_token=csrf_token) }}
|
||||
</header>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{#
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
-#}
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main class="flex flex-col gap-6">
|
||||
<header class="page-heading">
|
||||
<div class="icon invalid">
|
||||
{{ icon.block() }}
|
||||
</div>
|
||||
|
||||
<div class="header">
|
||||
<h1 class="title">{{ _("mas.account.locked.heading") }}</h1>
|
||||
{% set mxid = "@" + user.username + ":" + branding.server_name %}
|
||||
<p class="text">{{ _("mas.account.locked.description", mxid=mxid) }}</p>
|
||||
</div>
|
||||
|
||||
{{ logout.button(text=_("action.sign_in"), csrf_token=csrf_token) }}
|
||||
</header>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{#
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
-#}
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main class="flex flex-col gap-6">
|
||||
<header class="page-heading">
|
||||
<div class="icon invalid">
|
||||
{{ icon.leave() }}
|
||||
</div>
|
||||
|
||||
<div class="header">
|
||||
<h1 class="title">{{ _("mas.account.logged_out.heading") }}</h1>
|
||||
<p class="text">{{ _("mas.account.logged_out.description") }}</p>
|
||||
</div>
|
||||
|
||||
{{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token) }}
|
||||
</header>
|
||||
</main>
|
||||
{% endblock %}
|
||||
+34
-2
@@ -18,11 +18,11 @@
|
||||
},
|
||||
"sign_in": "Sign in",
|
||||
"@sign_in": {
|
||||
"context": "pages/index.html:30:26-45"
|
||||
"context": "pages/account/deactivated.html:23:28-47, pages/account/locked.html:23:28-47, pages/index.html:30:26-45"
|
||||
},
|
||||
"sign_out": "Sign out",
|
||||
"@sign_out": {
|
||||
"context": "pages/consent.html:65:28-48, pages/device_consent.html:135:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46"
|
||||
"context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:135:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46"
|
||||
},
|
||||
"skip": "Skip",
|
||||
"@skip": {
|
||||
@@ -110,6 +110,38 @@
|
||||
}
|
||||
},
|
||||
"mas": {
|
||||
"account": {
|
||||
"deactivated": {
|
||||
"description": "This account (<em>%(mxid)s</em>) has been deleted. If this is not expected, contact your server administrator.",
|
||||
"@description": {
|
||||
"context": "pages/account/deactivated.html:20:27-78"
|
||||
},
|
||||
"heading": "Account deleted",
|
||||
"@heading": {
|
||||
"context": "pages/account/deactivated.html:18:29-65"
|
||||
}
|
||||
},
|
||||
"locked": {
|
||||
"description": "This account (<em>%(mxid)s</em>) has been locked. If this is not expected, contact your server administrator.",
|
||||
"@description": {
|
||||
"context": "pages/account/locked.html:20:27-73"
|
||||
},
|
||||
"heading": "Account locked",
|
||||
"@heading": {
|
||||
"context": "pages/account/locked.html:18:29-60"
|
||||
}
|
||||
},
|
||||
"logged_out": {
|
||||
"description": "This session has been terminated. Sign out to be able to log back in",
|
||||
"@description": {
|
||||
"context": "pages/account/logged_out.html:19:27-66"
|
||||
},
|
||||
"heading": "Session terminated",
|
||||
"@heading": {
|
||||
"context": "pages/account/logged_out.html:18:29-64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"back_to_homepage": "Go back to the homepage",
|
||||
"@back_to_homepage": {
|
||||
"context": "pages/404.html:16:29-54"
|
||||
|
||||
Reference in New Issue
Block a user