From 8882d1d4c7e857c034cc623b8fdb7a520640277e Mon Sep 17 00:00:00 2001 From: Ginger Date: Wed, 13 May 2026 10:02:23 -0400 Subject: [PATCH] feat: Improve account panel UI for locked and suspended accounts --- src/api/client/session.rs | 4 - src/web/pages/account/login.rs | 2 +- src/web/pages/account/mod.rs | 32 +++++-- src/web/pages/account/password/reset.rs | 4 +- src/web/pages/oauth/grant.rs | 2 +- src/web/pages/resources/components.css | 6 ++ src/web/pages/templates/_layout.html.j2 | 3 +- src/web/pages/templates/account.html.j2 | 99 +++++++++++++-------- src/web/pages/templates/device_info.html.j2 | 2 +- src/web/session/mod.rs | 38 +++++--- 10 files changed, 124 insertions(+), 68 deletions(-) diff --git a/src/api/client/session.rs b/src/api/client/session.rs index 4c66c462e..93f9fff20 100644 --- a/src/api/client/session.rs +++ b/src/api/client/session.rs @@ -92,10 +92,6 @@ pub async fn handle_login( return Err!(Request(InvalidParam("User ID does not belong to this homeserver"))); } - if services.users.is_locked(&user_id).await? { - return Err!(Request(UserLocked("This account has been locked."))); - } - if services.users.is_login_disabled(&user_id).await { warn!(%user_id, "user attempted to log in with a login-disabled account"); return Err!(Request(Forbidden("This account is not permitted to log in."))); diff --git a/src/web/pages/account/login.rs b/src/web/pages/account/login.rs index 2c555a8e7..52ef6cd69 100644 --- a/src/web/pages/account/login.rs +++ b/src/web/pages/account/login.rs @@ -63,7 +63,7 @@ async fn route_login( Extension(context): Extension, Expect(Query(LoginQuery { next, reauthenticate })): Expect>, session_store: Session, - user: User, + user: User, PostForm(form): PostForm, ) -> Result { let user_id = user.into_session().map(|session| session.user_id); diff --git a/src/web/pages/account/mod.rs b/src/web/pages/account/mod.rs index fec002337..ace945967 100644 --- a/src/web/pages/account/mod.rs +++ b/src/web/pages/account/mod.rs @@ -58,19 +58,34 @@ struct ThreepidQuery { template! { struct Account use "account.html.j2" { user_card: UserCard, + body: AccountBody + } +} + +#[derive(Debug)] +enum AccountBody { + Unlocked { + suspended: bool, email_requirement: EmailRequirement, email: Option, - devices: Vec - } + devices: Vec, + }, + Locked, } async fn get_account( State(services): State, Extension(context): Extension, - user: User, + user: User, ) -> Result { let user_id = user.expect(LoginTarget::Account)?; + let user_card = UserCard::for_local_user(&services, user_id.clone()).await; + + if services.users.is_locked(&user_id).await.unwrap() { + return response!(Account::new(context, user_card, AccountBody::Locked)); + } + let email_requirement = services.threepid.email_requirement(); let email = services .threepid @@ -78,8 +93,6 @@ async fn get_account( .await .map(|address| address.to_string()); - let user_card = UserCard::for_local_user(&services, user_id.clone()).await; - let dehydrated_device_id = services.users.get_dehydrated_device_id(&user_id).await.ok(); let mut devices: Vec<_> = services @@ -111,7 +124,14 @@ async fn get_account( .collect() .await; - response!(Account::new(context, user_card, email_requirement, email, device_cards)) + let suspended = services.users.is_suspended(&user_id).await.unwrap(); + + response!(Account::new(context, user_card, AccountBody::Unlocked { + suspended, + email_requirement, + email, + devices: device_cards + })) } #[derive(Deserialize)] diff --git a/src/web/pages/account/password/reset.rs b/src/web/pages/account/password/reset.rs index 5802ee8f8..7428fb4ab 100644 --- a/src/web/pages/account/password/reset.rs +++ b/src/web/pages/account/password/reset.rs @@ -197,7 +197,9 @@ async fn route_reset_password_validate( UserId::parse(format!("@{localpart}:{}", services.globals.server_name())) .unwrap(); - require_active(&services, &user_id).await?; + if let Err(response) = require_active(&services, &user_id, true).await { + return Ok(response); + } let user_card = UserCard::for_local_user(&services, user_id.clone()).await; diff --git a/src/web/pages/oauth/grant.rs b/src/web/pages/oauth/grant.rs index 4075f8e39..1ec5dae9d 100644 --- a/src/web/pages/oauth/grant.rs +++ b/src/web/pages/oauth/grant.rs @@ -42,7 +42,7 @@ template! { async fn route_authorization_code( State(services): State, Extension(context): Extension, - user: User, + user: User, Expect(Query(query)): Expect>, PostForm(form): PostForm<()>, ) -> Result { diff --git a/src/web/pages/resources/components.css b/src/web/pages/resources/components.css index a4cf3b649..a727e92c8 100644 --- a/src/web/pages/resources/components.css +++ b/src/web/pages/resources/components.css @@ -47,6 +47,12 @@ font-weight: normal; } } + + &.danger { + display: block; + background-color: oklch(from red 0.2 c h); + border: 1px dashed red; + } } .card-list { diff --git a/src/web/pages/templates/_layout.html.j2 b/src/web/pages/templates/_layout.html.j2 index c2688833f..df46d8236 100644 --- a/src/web/pages/templates/_layout.html.j2 +++ b/src/web/pages/templates/_layout.html.j2 @@ -27,7 +27,8 @@ {%~ else ~%} ({{ version_info }}) {%~ endif ~%} - {%~ endif ~%}

+ {%~ endif ~%} +

{%~ endblock ~%} diff --git a/src/web/pages/templates/account.html.j2 b/src/web/pages/templates/account.html.j2 index 6fe4c8013..e9c9c8286 100644 --- a/src/web/pages/templates/account.html.j2 +++ b/src/web/pages/templates/account.html.j2 @@ -8,46 +8,67 @@ Your account

Manage your account

{{ user_card }} -
- {% if email_requirement.may_change() %} -

- {% if let Some(email) = email %} - Your account's associated email address is {{ email }}. - {% else %} - Your account has no associated email address. + {% match body %} + {% when AccountBody::Unlocked { suspended, email_requirement, email, devices } %} + {% if suspended %} +

+ ⚠️ Your account has been suspended by your homeserver's administrator. + Some functionality may be restricted. +

+ {% endif %} +
+ {% if email_requirement.may_change() %} +

+ {% if let Some(email) = email %} + Your account's associated email address is {{ email }}. + {% else %} + Your account has no associated email address. + {% endif %} + Change your email +

{% endif %} - Change your email +

+ Change your password +

+
+ +
+ Log out +
+ +
+
+ Your devices ({{ devices.len() }}) +
+ {% for device in devices %} + {{ device }} + {% else %} + + Your account has no devices. Choose a client + and sign in to start chatting on Matrix. + + {% endfor %} +
+
+
+ +
+
+ Danger zone +

+ Settings here may affect the integrity of your account. +

+ Reset your digital identity • + Deactivate your account +
+
+ {% when AccountBody::Locked %} +

+ ⚠️ Your account has been locked by your homeserver's administrator.

- {% endif %} -

- Change your password -

-
- -
- Log out -
- -
-
- Your devices ({{ devices.len() }}) -
- {% for device in devices %} - {{ device }} - {% endfor %} -
-
-
- -
-
- Danger zone -

- Settings here may affect the integrity of your account. -

- Reset your digital identity • - Deactivate your account -
-
+
+ Log out +
+ {% endmatch %}
{%- endblock -%} diff --git a/src/web/pages/templates/device_info.html.j2 b/src/web/pages/templates/device_info.html.j2 index 267ade353..0bac15b9b 100644 --- a/src/web/pages/templates/device_info.html.j2 +++ b/src/web/pages/templates/device_info.html.j2 @@ -28,7 +28,7 @@ Device information {% else %} This device can access and control all features of your Matrix account.
- This is a legacy device. Legacy devices always have full access to your account. + ❖ This is a legacy device. Legacy devices always have full access to your account. {% endif %}

diff --git a/src/web/session/mod.rs b/src/web/session/mod.rs index 357f683fc..ccc1e6b27 100644 --- a/src/web/session/mod.rs +++ b/src/web/session/mod.rs @@ -4,7 +4,11 @@ use std::{ time::{Duration, SystemTime}, }; -use axum::{extract::FromRequestParts, http::request::Parts}; +use axum::{ + extract::FromRequestParts, + http::request::Parts, + response::{IntoResponse, Redirect, Response}, +}; use conduwuit_service::oauth::grant::AuthorizationCodeQuery; use ruma::{OwnedUserId, UserId}; use serde::{Deserialize, Serialize}; @@ -62,7 +66,7 @@ impl LoginTarget { } /// An extractor that fetches the authenticated user. -pub(crate) struct User(Option); +pub(crate) struct User(Option); #[derive(Serialize, Deserialize)] pub(crate) struct UserSession { @@ -89,7 +93,9 @@ impl UserSession { impl User { pub(crate) const KEY: &str = "session"; +} +impl User { /// Consume this extractor and return the user's session information. pub(crate) fn into_session(self) -> Option { self.0 } @@ -127,8 +133,8 @@ impl User { } } -impl FromRequestParts for User { - type Rejection = WebError; +impl FromRequestParts for User { + type Rejection = Response; async fn from_request_parts( parts: &mut Parts, @@ -139,12 +145,12 @@ impl FromRequestParts for User { .expect("should be able to extract session"); let session = session_store - .get::(Self::KEY) + .get::(User::KEY) .await .expect("should be able to deserialize session"); if let Some(session) = &session { - require_active(services, &session.user_id).await?; + require_active(services, &session.user_id, ALLOW_LOCKED).await?; } Ok(Self(session)) @@ -154,18 +160,22 @@ impl FromRequestParts for User { pub(crate) async fn require_active( services: &crate::State, user_id: &UserId, -) -> Result<(), WebError> { + allow_locked: bool, +) -> Result<(), Response> { if !services.users.is_active(user_id).await { - return Err(WebError::Forbidden("Your account is deactivated.".to_owned())); + return Err( + WebError::Forbidden("Your account is deactivated.".to_owned()).into_response() + ); } - if services - .users - .is_locked(user_id) - .await - .expect("should be able to check lock state") + if !allow_locked + && services + .users + .is_locked(user_id) + .await + .expect("should be able to check lock state") { - return Err(WebError::Forbidden("Your account is locked.".to_owned())); + return Err(Redirect::to(&format!("{ROUTE_PREFIX}/account/")).into_response()); } Ok(())