diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 1836f6d8a..53bfa899e 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -209,6 +209,7 @@ impl Options { site_config.clone(), password_manager.clone(), url_builder.clone(), + limiter.clone(), ); let state = { diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 17825f2ac..014eb85a8 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -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 diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index 73cefb15f..cb9965f98 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -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 @@ -53,7 +53,10 @@ use self::{ mutations::Mutation, query::Query, }; -use crate::{impl_from_error_for_route, passwords::PasswordManager, BoundActivityTracker}; +use crate::{ + impl_from_error_for_route, passwords::PasswordManager, BoundActivityTracker, Limiter, + RequesterFingerprint, +}; #[cfg(test)] mod tests; @@ -72,6 +75,7 @@ struct GraphQLState { site_config: SiteConfig, password_manager: PasswordManager, url_builder: UrlBuilder, + limiter: Limiter, } #[async_trait] @@ -104,6 +108,10 @@ impl state::State for GraphQLState { &self.url_builder } + fn limiter(&self) -> &Limiter { + &self.limiter + } + fn clock(&self) -> BoxClock { let clock = SystemClock::default(); Box::new(clock) @@ -126,6 +134,7 @@ pub fn schema( site_config: SiteConfig, password_manager: PasswordManager, url_builder: UrlBuilder, + limiter: Limiter, ) -> Schema { let state = GraphQLState { pool: pool.clone(), @@ -134,6 +143,7 @@ pub fn schema( site_config, password_manager, url_builder, + limiter, }; let state: BoxState = Box::new(state); @@ -303,6 +313,7 @@ pub async fn post( cookie_jar: CookieJar, content_type: Option>, authorization: Option>>, + requester_fingerprint: RequesterFingerprint, body: Body, ) -> Result { let body = body.into_data_stream(); @@ -329,6 +340,7 @@ pub async fn post( MultipartOptions::default(), ) .await? + .data(requester_fingerprint) .data(requester); // XXX: this should probably return another error response? let span = span_for_graphql_request(&request); @@ -355,6 +367,7 @@ pub async fn get( activity_tracker: BoundActivityTracker, cookie_jar: CookieJar, authorization: Option>>, + requester_fingerprint: RequesterFingerprint, RawQuery(query): RawQuery, ) -> Result { let token = authorization @@ -371,8 +384,9 @@ pub async fn get( ) .await?; - let request = - async_graphql::http::parse_query_string(&query.unwrap_or_default())?.data(requester); + let request = async_graphql::http::parse_query_string(&query.unwrap_or_default())? + .data(requester) + .data(requester_fingerprint); let span = span_for_graphql_request(&request); let response = schema.execute(request).instrument(span).await; diff --git a/crates/handlers/src/graphql/model/mod.rs b/crates/handlers/src/graphql/model/mod.rs index 988593121..be1fb346c 100644 --- a/crates/handlers/src/graphql/model/mod.rs +++ b/crates/handlers/src/graphql/model/mod.rs @@ -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 @@ -26,7 +26,7 @@ pub use self::{ oauth::{OAuth2Client, OAuth2Session}, site_config::{SiteConfig, SITE_CONFIG_ID}, upstream_oauth::{UpstreamOAuth2Link, UpstreamOAuth2Provider}, - users::{AppSession, User, UserEmail}, + users::{AppSession, User, UserEmail, UserRecoveryTicket}, viewer::{Anonymous, Viewer, ViewerSession}, }; @@ -42,6 +42,7 @@ pub enum CreationEvent { CompatSession(Box), BrowserSession(Box), UserEmail(Box), + UserRecoveryTicket(Box), UpstreamOAuth2Provider(Box), UpstreamOAuth2Link(Box), OAuth2Session(Box), diff --git a/crates/handlers/src/graphql/model/node.rs b/crates/handlers/src/graphql/model/node.rs index b5d666fa7..4e899638b 100644 --- a/crates/handlers/src/graphql/model/node.rs +++ b/crates/handlers/src/graphql/model/node.rs @@ -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 @@ -12,6 +12,7 @@ use ulid::Ulid; use super::{ Anonymous, Authentication, BrowserSession, CompatSession, CompatSsoLogin, OAuth2Client, OAuth2Session, SiteConfig, UpstreamOAuth2Link, UpstreamOAuth2Provider, User, UserEmail, + UserRecoveryTicket, }; #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -26,6 +27,7 @@ pub enum NodeType { UpstreamOAuth2Link, User, UserEmail, + UserRecoveryTicket, } #[derive(Debug, Error)] @@ -50,6 +52,7 @@ impl NodeType { NodeType::UpstreamOAuth2Link => "upstream_oauth2_link", NodeType::User => "user", NodeType::UserEmail => "user_email", + NodeType::UserRecoveryTicket => "user_recovery_ticket", } } @@ -65,6 +68,7 @@ impl NodeType { "upstream_oauth2_link" => Some(NodeType::UpstreamOAuth2Link), "user" => Some(NodeType::User), "user_email" => Some(NodeType::UserEmail), + "user_recovery_ticket" => Some(NodeType::UserRecoveryTicket), _ => None, } } @@ -120,4 +124,5 @@ pub enum Node { UpstreamOAuth2Link(Box), User(Box), UserEmail(Box), + UserRecoveryTicket(Box), } diff --git a/crates/handlers/src/graphql/model/users.rs b/crates/handlers/src/graphql/model/users.rs index 84b159338..77606a1d8 100644 --- a/crates/handlers/src/graphql/model/users.rs +++ b/crates/handlers/src/graphql/model/users.rs @@ -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 @@ -765,3 +765,101 @@ pub enum UserEmailState { /// The email address has been confirmed. Confirmed, } + +/// A recovery ticket +#[derive(Description)] +pub struct UserRecoveryTicket(pub mas_data_model::UserRecoveryTicket); + +/// The status of a recovery ticket +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum UserRecoveryTicketStatus { + /// The ticket is valid + Valid, + + /// The ticket has expired + Expired, + + /// The ticket has been consumed + Consumed, +} + +#[Object(use_type_description)] +impl UserRecoveryTicket { + /// ID of the object. + pub async fn id(&self) -> ID { + NodeType::UserRecoveryTicket.id(self.0.id) + } + + /// When the object was created. + pub async fn created_at(&self) -> DateTime { + self.0.created_at + } + + /// The status of the ticket + pub async fn status( + &self, + context: &Context<'_>, + ) -> Result { + let state = context.state(); + let clock = state.clock(); + let mut repo = state.repository().await?; + + // Lookup the session associated with the ticket + let session = repo + .user_recovery() + .lookup_session(self.0.user_recovery_session_id) + .await? + .context("Failed to lookup session")?; + repo.cancel().await?; + + if session.consumed_at.is_some() { + return Ok(UserRecoveryTicketStatus::Consumed); + } + + if self.0.expires_at < clock.now() { + return Ok(UserRecoveryTicketStatus::Expired); + } + + Ok(UserRecoveryTicketStatus::Valid) + } + + /// The username associated with this ticket + pub async fn username(&self, ctx: &Context<'_>) -> Result { + // We could expose the UserEmail, then the User, but this is unauthenticated, so + // we don't want to risk leaking too many objects. Instead, we just give the + // username as a property of the UserRecoveryTicket + let state = ctx.state(); + let mut repo = state.repository().await?; + let user_email = repo + .user_email() + .lookup(self.0.user_email_id) + .await? + .context("Failed to lookup user email")?; + + let user = repo + .user() + .lookup(user_email.user_id) + .await? + .context("Failed to lookup user")?; + repo.cancel().await?; + + Ok(user.username) + } + + /// The email address associated with this ticket + pub async fn email(&self, ctx: &Context<'_>) -> Result { + // We could expose the UserEmail directly, but this is unauthenticated, so we + // don't want to risk leaking too many objects. Instead, we just give + // the email as a property of the UserRecoveryTicket + let state = ctx.state(); + let mut repo = state.repository().await?; + let user_email = repo + .user_email() + .lookup(self.0.user_email_id) + .await? + .context("Failed to lookup user email")?; + repo.cancel().await?; + + Ok(user_email.email) + } +} diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index 04d9cc9b3..7bfb4c3d6 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -7,10 +7,15 @@ use anyhow::Context as _; use async_graphql::{Context, Description, Enum, InputObject, Object, ID}; use mas_storage::{ - queue::{DeactivateUserJob, ProvisionUserJob, QueueJobRepositoryExt as _}, + queue::{ + DeactivateUserJob, ProvisionUserJob, QueueJobRepositoryExt as _, + SendAccountRecoveryEmailsJob, + }, user::UserRepository, }; use tracing::{info, warn}; +use ulid::Ulid; +use url::Url; use zeroize::Zeroizing; use crate::graphql::{ @@ -323,6 +328,61 @@ impl SetPasswordPayload { } } +/// The input for the `resendRecoveryEmail` mutation. +#[derive(InputObject)] +pub struct ResendRecoveryEmailInput { + /// The recovery ticket to use. + ticket: String, +} + +/// The return type for the `resendRecoveryEmail` mutation. +#[derive(Description)] +pub enum ResendRecoveryEmailPayload { + NoSuchRecoveryTicket, + RateLimited, + Sent { recovery_session_id: Ulid }, +} + +/// The status of the `resendRecoveryEmail` mutation. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum ResendRecoveryEmailStatus { + /// The recovery ticket was not found. + NoSuchRecoveryTicket, + + /// The rate limit was exceeded. + RateLimited, + + /// The recovery email was sent. + Sent, +} + +#[Object(use_type_description)] +impl ResendRecoveryEmailPayload { + /// Status of the operation + async fn status(&self) -> ResendRecoveryEmailStatus { + match self { + Self::NoSuchRecoveryTicket => ResendRecoveryEmailStatus::NoSuchRecoveryTicket, + Self::RateLimited => ResendRecoveryEmailStatus::RateLimited, + Self::Sent { .. } => ResendRecoveryEmailStatus::Sent, + } + } + + /// URL to continue the recovery process + async fn progress_url(&self, context: &Context<'_>) -> Option { + let state = context.state(); + let url_builder = state.url_builder(); + match self { + Self::NoSuchRecoveryTicket | Self::RateLimited => None, + Self::Sent { + recovery_session_id, + } => { + let route = mas_router::AccountRecoveryProgress::new(*recovery_session_id); + Some(url_builder.absolute_url_for(&route)) + } + } + } +} + fn valid_username_character(c: char) -> bool { c.is_ascii_lowercase() || c.is_ascii_digit() @@ -760,4 +820,54 @@ impl UserMutations { status: SetPasswordStatus::Allowed, }) } + + /// Resend a user recovery email + /// + /// This is used when a user opens a recovery link that has expired. In this + /// case, we display a link for them to get a new recovery email, which + /// calls this mutation. + pub async fn resend_recovery_email( + &self, + ctx: &Context<'_>, + input: ResendRecoveryEmailInput, + ) -> Result { + let state = ctx.state(); + let requester_fingerprint = ctx.requester_fingerprint(); + let clock = state.clock(); + let mut rng = state.rng(); + let limiter = state.limiter(); + let mut repo = state.repository().await?; + + let Some(recovery_ticket) = repo.user_recovery().find_ticket(&input.ticket).await? else { + return Ok(ResendRecoveryEmailPayload::NoSuchRecoveryTicket); + }; + + let recovery_session = repo + .user_recovery() + .lookup_session(recovery_ticket.user_recovery_session_id) + .await? + .context("Could not load recovery session")?; + + if let Err(e) = + limiter.check_account_recovery(requester_fingerprint, &recovery_session.email) + { + tracing::warn!(error = &e as &dyn std::error::Error); + return Ok(ResendRecoveryEmailPayload::RateLimited); + } + + // Schedule a new batch of emails + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + SendAccountRecoveryEmailsJob::new(&recovery_session), + ) + .await?; + + repo.save().await?; + + Ok(ResendRecoveryEmailPayload::Sent { + recovery_session_id: recovery_session.id, + }) + } } diff --git a/crates/handlers/src/graphql/query/mod.rs b/crates/handlers/src/graphql/query/mod.rs index fd17417ea..ab57a5f0b 100644 --- a/crates/handlers/src/graphql/query/mod.rs +++ b/crates/handlers/src/graphql/query/mod.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -9,7 +9,7 @@ use async_graphql::{Context, MergedObject, Object, ID}; use crate::graphql::{ model::{ Anonymous, BrowserSession, CompatSession, Node, NodeType, OAuth2Client, OAuth2Session, - SiteConfig, User, UserEmail, + SiteConfig, User, UserEmail, UserRecoveryTicket, }, state::ContextExt, }; @@ -182,6 +182,20 @@ impl BaseQuery { Ok(Some(UserEmail(user_email))) } + /// Fetch a user recovery ticket. + async fn user_recovery_ticket( + &self, + ctx: &Context<'_>, + ticket: String, + ) -> Result, async_graphql::Error> { + let state = ctx.state(); + let mut repo = state.repository().await?; + let ticket = repo.user_recovery().find_ticket(&ticket).await?; + repo.cancel().await?; + + Ok(ticket.map(UserRecoveryTicket)) + } + /// Fetches an object given its ID. async fn node(&self, ctx: &Context<'_>, id: ID) -> Result, async_graphql::Error> { // Special case for the anonymous user @@ -199,7 +213,9 @@ impl BaseQuery { let ret = match node_type { // TODO - NodeType::Authentication | NodeType::CompatSsoLogin => None, + NodeType::Authentication | NodeType::CompatSsoLogin | NodeType::UserRecoveryTicket => { + None + } NodeType::UpstreamOAuth2Provider => UpstreamOAuthQuery .upstream_oauth2_provider(ctx, id) diff --git a/crates/handlers/src/graphql/state.rs b/crates/handlers/src/graphql/state.rs index f5d01ea4d..874f6f7aa 100644 --- a/crates/handlers/src/graphql/state.rs +++ b/crates/handlers/src/graphql/state.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -10,7 +10,7 @@ use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{BoxClock, BoxRepository, BoxRng, RepositoryError}; -use crate::{graphql::Requester, passwords::PasswordManager}; +use crate::{graphql::Requester, passwords::PasswordManager, Limiter, RequesterFingerprint}; #[async_trait::async_trait] pub trait State { @@ -22,6 +22,7 @@ pub trait State { fn rng(&self) -> BoxRng; fn site_config(&self) -> &SiteConfig; fn url_builder(&self) -> &UrlBuilder; + fn limiter(&self) -> &Limiter; } pub type BoxState = Box; @@ -30,6 +31,8 @@ pub trait ContextExt { fn state(&self) -> &BoxState; fn requester(&self) -> &Requester; + + fn requester_fingerprint(&self) -> RequesterFingerprint; } impl ContextExt for async_graphql::Context<'_> { @@ -40,4 +43,8 @@ impl ContextExt for async_graphql::Context<'_> { fn requester(&self) -> &Requester { self.data_unchecked() } + + fn requester_fingerprint(&self) -> RequesterFingerprint { + *self.data_unchecked() + } } diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index e2daee59c..286ceb4b3 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -118,6 +118,8 @@ where BoxClock: FromRequestParts, Encrypter: FromRef, CookieJar: FromRequestParts, + Limiter: FromRef, + RequesterFingerprint: FromRequestParts, { let mut router = Router::new() .route( diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 0551d3737..2ba58414d 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -204,6 +204,8 @@ impl TestState { let clock = Arc::new(MockClock::default()); let rng = Arc::new(Mutex::new(ChaChaRng::seed_from_u64(42))); + let limiter = Limiter::new(&RateLimitingConfig::default()).unwrap(); + let graphql_state = TestGraphQLState { pool: pool.clone(), policy_factory: Arc::clone(&policy_factory), @@ -213,6 +215,7 @@ impl TestState { clock: Arc::clone(&clock), password_manager: password_manager.clone(), url_builder: url_builder.clone(), + limiter: limiter.clone(), }; let state: crate::graphql::BoxState = Box::new(graphql_state); @@ -225,8 +228,6 @@ impl TestState { shutdown_token.child_token(), ); - let limiter = Limiter::new(&RateLimitingConfig::default()).unwrap(); - Ok(Self { pool, templates, @@ -379,6 +380,7 @@ struct TestGraphQLState { rng: Arc>, password_manager: PasswordManager, url_builder: UrlBuilder, + limiter: Limiter, } #[async_trait] @@ -415,6 +417,10 @@ impl graphql::State for TestGraphQLState { &self.site_config } + fn limiter(&self) -> &Limiter { + &self.limiter + } + fn rng(&self) -> BoxRng { let mut parent_rng = self.rng.lock().expect("Failed to lock RNG"); let rng = ChaChaRng::from_rng(&mut *parent_rng).expect("Failed to seed RNG"); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index cdd48a779..e139cb724 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -4,10 +4,13 @@ "cancel": "Cancel", "clear": "Clear", "close": "Close", + "collapse": "Collapse", "continue": "Continue", "edit": "Edit", + "expand": "Expand", "save": "Save", - "save_and_continue": "Save and continue" + "save_and_continue": "Save and continue", + "start_over": "Start over" }, "branding": { "privacy_policy": { @@ -29,6 +32,8 @@ }, "frontend": { "account": { + "account_password": "Account password", + "contact_info": "Contact info", "edit_profile": { "display_name_help": "This is what others will see wherever you’re signed in.", "display_name_label": "Display name", @@ -84,7 +89,8 @@ "title": "Something went wrong" }, "errors": { - "field_required": "This field is required" + "field_required": "This field is required", + "rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again." }, "last_active": { "active_date": "Active {{relativeDate}}", @@ -137,6 +143,16 @@ "title": "Change your password" }, "password_reset": { + "consumed": { + "subtitle": "To create a new password, start over and select ”Forgot password“.", + "title": "The link to reset your password has already been used" + }, + "expired": { + "resend_email": "Resend email", + "subtitle": "Request a new email that will be sent to: {{email}}", + "title": "The link to reset your password has expired" + }, + "subtitle": "Choose a new password for your account.", "title": "Reset your password" }, "password_strength": { diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 568d3f240..3eb56573e 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -804,6 +804,16 @@ type Mutation { """ setPasswordByRecovery(input: SetPasswordByRecoveryInput!): SetPasswordPayload! """ + Resend a user recovery email + + This is used when a user opens a recovery link that has expired. In this + case, we display a link for them to get a new recovery email, which + calls this mutation. + """ + resendRecoveryEmail( + input: ResendRecoveryEmailInput! + ): ResendRecoveryEmailPayload! + """ Create a new arbitrary OAuth 2.0 Session. Only available for administrators. @@ -1026,6 +1036,10 @@ type Query { """ userEmail(id: ID!): UserEmail """ + Fetch a user recovery ticket. + """ + userRecoveryTicket(ticket: String!): UserRecoveryTicket + """ Fetches an object given its ID. """ node(id: ID!): Node @@ -1161,6 +1175,48 @@ enum RemoveEmailStatus { NOT_FOUND } +""" +The input for the `resendRecoveryEmail` mutation. +""" +input ResendRecoveryEmailInput { + """ + The recovery ticket to use. + """ + ticket: String! +} + +""" +The return type for the `resendRecoveryEmail` mutation. +""" +type ResendRecoveryEmailPayload { + """ + Status of the operation + """ + status: ResendRecoveryEmailStatus! + """ + URL to continue the recovery process + """ + progressUrl: Url +} + +""" +The status of the `resendRecoveryEmail` mutation. +""" +enum ResendRecoveryEmailStatus { + """ + The recovery ticket was not found. + """ + NO_SUCH_RECOVERY_TICKET + """ + The rate limit was exceeded. + """ + RATE_LIMITED + """ + The recovery email was sent. + """ + SENT +} + """ The input for the `sendVerificationEmail` mutation """ @@ -2023,6 +2079,50 @@ enum UserEmailState { CONFIRMED } +""" +A recovery ticket +""" +type UserRecoveryTicket implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + When the object was created. + """ + createdAt: DateTime! + """ + The status of the ticket + """ + status: UserRecoveryTicketStatus! + """ + The username associated with this ticket + """ + username: String! + """ + The email address associated with this ticket + """ + email: String! +} + +""" +The status of a recovery ticket +""" +enum UserRecoveryTicketStatus { + """ + The ticket is valid + """ + VALID + """ + The ticket has expired + """ + EXPIRED + """ + The ticket has been consumed + """ + CONSUMED +} + """ The state of a user. """ diff --git a/frontend/src/components/Collapsible/Collapsible.module.css b/frontend/src/components/Collapsible/Collapsible.module.css index 4c9560964..5da9b4ed8 100644 --- a/frontend/src/components/Collapsible/Collapsible.module.css +++ b/frontend/src/components/Collapsible/Collapsible.module.css @@ -1,24 +1,52 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. +/* Copyright 2024, 2025 New Vector Ltd. + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. */ +.root { + display: flex; + flex-direction: column; + gap: var(--cpd-space-6x); +} + +.heading { + display: flex; + flex-direction: column; + gap: var(--cpd-space-2x); +} + .trigger { display: flex; width: 100%; + align-items: center; + justify-content: space-between; + text-align: start; + gap: var(--cpd-space-2x); } .trigger-title { + cursor: pointer; flex-grow: 1; - text-align: start; } -[data-state="closed"] .trigger-icon { +.trigger-icon { + transition: transform 0.1s ease-out; +} + +.root[data-state="closed"] .trigger-icon { transform: rotate(180deg); } +.description { + color: var(--cpd-color-text-secondary); + font: var(--cpd-font-body-md-regular); + letter-spacing: var(--cpd-font-letter-spacing-body-md); +} + .content { - margin-top: var(--cpd-space-2x); + display: flex; + flex-direction: column; + gap: var(--cpd-space-6x); } diff --git a/frontend/src/components/Collapsible/Collapsible.stories.tsx b/frontend/src/components/Collapsible/Collapsible.stories.tsx new file mode 100644 index 000000000..33423c4fa --- /dev/null +++ b/frontend/src/components/Collapsible/Collapsible.stories.tsx @@ -0,0 +1,29 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import type { Meta, StoryObj } from "@storybook/react"; +import * as Collapsible from "./Collapsible"; + +const meta = { + title: "UI/Collapsible", + component: Collapsible.Section, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + title: "Section name", + description: "Optional section description", + children: ( +
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

+

Sed id felis eget orci aliquet tincidunt.

+
+ ), + }, +}; diff --git a/frontend/src/components/Collapsible/Collapsible.tsx b/frontend/src/components/Collapsible/Collapsible.tsx index 9c9dab359..fb3181445 100644 --- a/frontend/src/components/Collapsible/Collapsible.tsx +++ b/frontend/src/components/Collapsible/Collapsible.tsx @@ -6,37 +6,67 @@ import * as Collapsible from "@radix-ui/react-collapsible"; import IconChevronUp from "@vector-im/compound-design-tokens/assets/web/icons/chevron-up"; +import { H4, IconButton } from "@vector-im/compound-web"; import classNames from "classnames"; +import { useCallback, useId, useState } from "react"; +import { useTranslation } from "react-i18next"; import styles from "./Collapsible.module.css"; -export const Trigger: React.FC< - React.ComponentProps -> = ({ children, className, ...props }) => { +export const Section: React.FC< + { + title: string; + description?: string; + } & Omit< + React.ComponentProps, + "asChild" | "aria-labelledby" | "aria-describedby" | "open" + > +> = ({ title, description, defaultOpen, className, children, ...props }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(defaultOpen || false); + const titleId = useId(); + const descriptionId = useId(); + const onClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setOpen((open) => !open); + }, []); + return ( - -
{children}
- -
- ); -}; +
+
+
+

+ {title} +

+ + + + + +
-export const Content: React.FC< - React.ComponentProps -> = ({ className, ...props }) => { - return ( - + {description && ( +

+ {description} +

+ )} +
+ + +
{children}
+
+
+ ); }; - -export const Root = Collapsible.Root; diff --git a/frontend/src/components/Layout/Layout.module.css b/frontend/src/components/Layout/Layout.module.css index 79347e4db..04f36211e 100644 --- a/frontend/src/components/Layout/Layout.module.css +++ b/frontend/src/components/Layout/Layout.module.css @@ -6,33 +6,34 @@ */ .layout-container { + --target-width: 378px; + --inline-padding: var(--cpd-space-4x); box-sizing: border-box; display: flex; flex-direction: column; - max-width: calc(378px + var(--cpd-space-5x) * 2); + max-width: calc(var(--target-width) + var(--inline-padding) * 2); /* Fallback for browsers that do not support 100svh */ min-height: 100vh; min-height: 100svh; margin: 0 auto; - padding-inline: var(--cpd-space-5x); - padding-block: var(--cpd-space-12x); + padding-inline: var(--inline-padding); + padding-block: var(--cpd-space-8x); gap: var(--cpd-space-8x); &.consent { - max-width: calc(460px + var(--cpd-space-5x) * 2); + --target-width: 460px; } &.wide { - max-width: calc(520px + var(--cpd-space-5x) * 2); + --target-width: 520px; } } @media screen and (min-width: 768px) { .layout-container { - padding-block-start: var(--cpd-space-20x); - padding-block-end: var(--cpd-space-10x); + padding-block: var(--cpd-space-12x); } } diff --git a/frontend/src/components/Layout/Layout.tsx b/frontend/src/components/Layout/Layout.tsx index 989a1f70c..207478cc7 100644 --- a/frontend/src/components/Layout/Layout.tsx +++ b/frontend/src/components/Layout/Layout.tsx @@ -4,7 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -import { queryOptions, useQuery } from "@tanstack/react-query"; +import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; import cx from "classnames"; import { Suspense } from "react"; import { graphql } from "../../gql"; @@ -28,7 +28,7 @@ export const query = queryOptions({ }); const AsyncFooter: React.FC = () => { - const result = useQuery(query); + const result = useSuspenseQuery(query); if (result.error || result.isPending) { // We probably prefer to render an empty footer in case of an error diff --git a/frontend/src/components/PageHeading/PageHeading.module.css b/frontend/src/components/PageHeading/PageHeading.module.css index 3ce16f96b..df8ed4900 100644 --- a/frontend/src/components/PageHeading/PageHeading.module.css +++ b/frontend/src/components/PageHeading/PageHeading.module.css @@ -10,7 +10,7 @@ flex-direction: column; gap: var(--cpd-space-4x); - /* Layout already has 6x padding, and we need 10x */ + /* Layout already has 8x/12x padding, and we need 12x/20x */ margin-block-start: var(--cpd-space-4x); & .icon { @@ -74,3 +74,9 @@ } } } + +@media screen and (min-width: 768px) { + .page-heading { + margin-block-start: var(--cpd-space-8x); + } +} diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 349a63e9a..d6334121d 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -58,7 +58,10 @@ const documents = { "\n mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\n }\n }\n": types.ChangePasswordDocument, "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": types.PasswordChangeDocument, "\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": types.RecoverPasswordDocument, - "\n query PasswordRecovery {\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": types.PasswordRecoveryDocument, + "\n mutation ResendRecoveryEmail($ticket: String!) {\n resendRecoveryEmail(input: { ticket: $ticket }) {\n status\n progressUrl\n }\n }\n": types.ResendRecoveryEmailDocument, + "\n fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {\n username\n email\n }\n": types.RecoverPassword_UserRecoveryTicketFragmentDoc, + "\n fragment RecoverPassword_siteConfig on SiteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n": types.RecoverPassword_SiteConfigFragmentDoc, + "\n query PasswordRecovery($ticket: String!) {\n siteConfig {\n ...RecoverPassword_siteConfig\n }\n\n userRecoveryTicket(ticket: $ticket) {\n status\n ...RecoverPassword_userRecoveryTicket\n }\n }\n": types.PasswordRecoveryDocument, "\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n": types.AllowCrossSigningResetDocument, }; @@ -237,7 +240,19 @@ export function graphql(source: "\n mutation RecoverPassword($ticket: String!, /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query PasswordRecovery {\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n"): typeof import('./graphql').PasswordRecoveryDocument; +export function graphql(source: "\n mutation ResendRecoveryEmail($ticket: String!) {\n resendRecoveryEmail(input: { ticket: $ticket }) {\n status\n progressUrl\n }\n }\n"): typeof import('./graphql').ResendRecoveryEmailDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {\n username\n email\n }\n"): typeof import('./graphql').RecoverPassword_UserRecoveryTicketFragmentDoc; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n fragment RecoverPassword_siteConfig on SiteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n"): typeof import('./graphql').RecoverPassword_SiteConfigFragmentDoc; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query PasswordRecovery($ticket: String!) {\n siteConfig {\n ...RecoverPassword_siteConfig\n }\n\n userRecoveryTicket(ticket: $ticket) {\n status\n ...RecoverPassword_userRecoveryTicket\n }\n }\n"): typeof import('./graphql').PasswordRecoveryDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 6876b5d98..3980ea976 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -493,6 +493,14 @@ export type Mutation = { lockUser: LockUserPayload; /** Remove an email address */ removeEmail: RemoveEmailPayload; + /** + * Resend a user recovery email + * + * This is used when a user opens a recovery link that has expired. In this + * case, we display a link for them to get a new recovery email, which + * calls this mutation. + */ + resendRecoveryEmail: ResendRecoveryEmailPayload; /** Send a verification code for an email address */ sendVerificationEmail: SendVerificationEmailPayload; /** @@ -576,6 +584,12 @@ export type MutationRemoveEmailArgs = { }; +/** The mutations root of the GraphQL interface. */ +export type MutationResendRecoveryEmailArgs = { + input: ResendRecoveryEmailInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationSendVerificationEmailArgs = { input: SendVerificationEmailInput; @@ -762,6 +776,8 @@ export type Query = { userByUsername?: Maybe; /** Fetch a user email by its ID. */ userEmail?: Maybe; + /** Fetch a user recovery ticket. */ + userRecoveryTicket?: Maybe; /** * Get a list of users. * @@ -851,6 +867,12 @@ export type QueryUserEmailArgs = { }; +/** The query root of the GraphQL interface. */ +export type QueryUserRecoveryTicketArgs = { + ticket: Scalars['String']['input']; +}; + + /** The query root of the GraphQL interface. */ export type QueryUsersArgs = { after?: InputMaybe; @@ -887,6 +909,30 @@ export type RemoveEmailStatus = /** The email address was removed */ | 'REMOVED'; +/** The input for the `resendRecoveryEmail` mutation. */ +export type ResendRecoveryEmailInput = { + /** The recovery ticket to use. */ + ticket: Scalars['String']['input']; +}; + +/** The return type for the `resendRecoveryEmail` mutation. */ +export type ResendRecoveryEmailPayload = { + __typename?: 'ResendRecoveryEmailPayload'; + /** URL to continue the recovery process */ + progressUrl?: Maybe; + /** Status of the operation */ + status: ResendRecoveryEmailStatus; +}; + +/** The status of the `resendRecoveryEmail` mutation. */ +export type ResendRecoveryEmailStatus = + /** The recovery ticket was not found. */ + | 'NO_SUCH_RECOVERY_TICKET' + /** The rate limit was exceeded. */ + | 'RATE_LIMITED' + /** The recovery email was sent. */ + | 'SENT'; + /** The input for the `sendVerificationEmail` mutation */ export type SendVerificationEmailInput = { /** The ID of the email address to verify */ @@ -1388,6 +1434,30 @@ export type UserEmailState = /** The email address is pending confirmation. */ | 'PENDING'; +/** A recovery ticket */ +export type UserRecoveryTicket = CreationEvent & Node & { + __typename?: 'UserRecoveryTicket'; + /** When the object was created. */ + createdAt: Scalars['DateTime']['output']; + /** The email address associated with this ticket */ + email: Scalars['String']['output']; + /** ID of the object. */ + id: Scalars['ID']['output']; + /** The status of the ticket */ + status: UserRecoveryTicketStatus; + /** The username associated with this ticket */ + username: Scalars['String']['output']; +}; + +/** The status of a recovery ticket */ +export type UserRecoveryTicketStatus = + /** The ticket has been consumed */ + | 'CONSUMED' + /** The ticket has expired */ + | 'EXPIRED' + /** The ticket is valid */ + | 'VALID'; + /** The state of a user. */ export type UserState = /** The user is active. */ @@ -1598,7 +1668,7 @@ export type SessionDetailQuery = { __typename?: 'Query', viewerSession: { __type ) | { __typename: 'CompatSsoLogin', id: string } | { __typename: 'Oauth2Client', id: string } | ( { __typename: 'Oauth2Session', id: string } & { ' $fragmentRefs'?: { 'OAuth2Session_DetailFragment': OAuth2Session_DetailFragment } } - ) | { __typename: 'SiteConfig', id: string } | { __typename: 'UpstreamOAuth2Link', id: string } | { __typename: 'UpstreamOAuth2Provider', id: string } | { __typename: 'User', id: string } | { __typename: 'UserEmail', id: string } | null }; + ) | { __typename: 'SiteConfig', id: string } | { __typename: 'UpstreamOAuth2Link', id: string } | { __typename: 'UpstreamOAuth2Provider', id: string } | { __typename: 'User', id: string } | { __typename: 'UserEmail', id: string } | { __typename: 'UserRecoveryTicket', id: string } | null }; export type BrowserSessionListQueryVariables = Exact<{ first?: InputMaybe; @@ -1708,13 +1778,32 @@ export type RecoverPasswordMutationVariables = Exact<{ export type RecoverPasswordMutation = { __typename?: 'Mutation', setPasswordByRecovery: { __typename?: 'SetPasswordPayload', status: SetPasswordStatus } }; -export type PasswordRecoveryQueryVariables = Exact<{ [key: string]: never; }>; +export type ResendRecoveryEmailMutationVariables = Exact<{ + ticket: Scalars['String']['input']; +}>; + + +export type ResendRecoveryEmailMutation = { __typename?: 'Mutation', resendRecoveryEmail: { __typename?: 'ResendRecoveryEmailPayload', status: ResendRecoveryEmailStatus, progressUrl?: string | null } }; + +export type RecoverPassword_UserRecoveryTicketFragment = { __typename?: 'UserRecoveryTicket', username: string, email: string } & { ' $fragmentName'?: 'RecoverPassword_UserRecoveryTicketFragment' }; + +export type RecoverPassword_SiteConfigFragment = ( + { __typename?: 'SiteConfig' } + & { ' $fragmentRefs'?: { 'PasswordCreationDoubleInput_SiteConfigFragment': PasswordCreationDoubleInput_SiteConfigFragment } } +) & { ' $fragmentName'?: 'RecoverPassword_SiteConfigFragment' }; + +export type PasswordRecoveryQueryVariables = Exact<{ + ticket: Scalars['String']['input']; +}>; export type PasswordRecoveryQuery = { __typename?: 'Query', siteConfig: ( { __typename?: 'SiteConfig' } - & { ' $fragmentRefs'?: { 'PasswordCreationDoubleInput_SiteConfigFragment': PasswordCreationDoubleInput_SiteConfigFragment } } - ) }; + & { ' $fragmentRefs'?: { 'RecoverPassword_SiteConfigFragment': RecoverPassword_SiteConfigFragment } } + ), userRecoveryTicket?: ( + { __typename?: 'UserRecoveryTicket', status: UserRecoveryTicketStatus } + & { ' $fragmentRefs'?: { 'RecoverPassword_UserRecoveryTicketFragment': RecoverPassword_UserRecoveryTicketFragment } } + ) | null }; export type AllowCrossSigningResetMutationVariables = Exact<{ userId: Scalars['ID']['input']; @@ -1825,12 +1914,6 @@ export const OAuth2Session_SessionFragmentDoc = new TypedDocumentString(` } } `, {"fragmentName":"OAuth2Session_session"}) as unknown as TypedDocumentString; -export const PasswordCreationDoubleInput_SiteConfigFragmentDoc = new TypedDocumentString(` - fragment PasswordCreationDoubleInput_siteConfig on SiteConfig { - id - minimumPasswordComplexity -} - `, {"fragmentName":"PasswordCreationDoubleInput_siteConfig"}) as unknown as TypedDocumentString; export const BrowserSession_DetailFragmentDoc = new TypedDocumentString(` fragment BrowserSession_detail on BrowserSession { id @@ -1951,6 +2034,26 @@ export const UserEmail_VerifyEmailFragmentDoc = new TypedDocumentString(` email } `, {"fragmentName":"UserEmail_verifyEmail"}) as unknown as TypedDocumentString; +export const RecoverPassword_UserRecoveryTicketFragmentDoc = new TypedDocumentString(` + fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket { + username + email +} + `, {"fragmentName":"RecoverPassword_userRecoveryTicket"}) as unknown as TypedDocumentString; +export const PasswordCreationDoubleInput_SiteConfigFragmentDoc = new TypedDocumentString(` + fragment PasswordCreationDoubleInput_siteConfig on SiteConfig { + id + minimumPasswordComplexity +} + `, {"fragmentName":"PasswordCreationDoubleInput_siteConfig"}) as unknown as TypedDocumentString; +export const RecoverPassword_SiteConfigFragmentDoc = new TypedDocumentString(` + fragment RecoverPassword_siteConfig on SiteConfig { + ...PasswordCreationDoubleInput_siteConfig +} + fragment PasswordCreationDoubleInput_siteConfig on SiteConfig { + id + minimumPasswordComplexity +}`, {"fragmentName":"RecoverPassword_siteConfig"}) as unknown as TypedDocumentString; export const EndBrowserSessionDocument = new TypedDocumentString(` mutation EndBrowserSession($id: ID!) { endBrowserSession(input: {browserSessionId: $id}) { @@ -2485,15 +2588,34 @@ export const RecoverPasswordDocument = new TypedDocumentString(` } } `) as unknown as TypedDocumentString; +export const ResendRecoveryEmailDocument = new TypedDocumentString(` + mutation ResendRecoveryEmail($ticket: String!) { + resendRecoveryEmail(input: {ticket: $ticket}) { + status + progressUrl + } +} + `) as unknown as TypedDocumentString; export const PasswordRecoveryDocument = new TypedDocumentString(` - query PasswordRecovery { + query PasswordRecovery($ticket: String!) { siteConfig { - ...PasswordCreationDoubleInput_siteConfig + ...RecoverPassword_siteConfig + } + userRecoveryTicket(ticket: $ticket) { + status + ...RecoverPassword_userRecoveryTicket } } fragment PasswordCreationDoubleInput_siteConfig on SiteConfig { id minimumPasswordComplexity +} +fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket { + username + email +} +fragment RecoverPassword_siteConfig on SiteConfig { + ...PasswordCreationDoubleInput_siteConfig }`) as unknown as TypedDocumentString; export const AllowCrossSigningResetDocument = new TypedDocumentString(` mutation AllowCrossSigningReset($userId: ID!) { @@ -3027,6 +3149,28 @@ export const mockRecoverPasswordMutation = (resolver: GraphQLResponseResolver { + * const { ticket } = variables; + * return HttpResponse.json({ + * data: { resendRecoveryEmail } + * }) + * }, + * requestOptions + * ) + */ +export const mockResendRecoveryEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'ResendRecoveryEmail', + resolver, + options + ) + /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) @@ -3034,8 +3178,9 @@ export const mockRecoverPasswordMutation = (resolver: GraphQLResponseResolver { + * const { ticket } = variables; * return HttpResponse.json({ - * data: { siteConfig } + * data: { siteConfig, userRecoveryTicket } * }) * }, * requestOptions diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index efd495292..02d3ed55d 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -5,6 +5,7 @@ // Please see LICENSE in the repository root for full details. import { createRouter } from "@tanstack/react-router"; +import LoadingScreen from "./components/LoadingScreen"; import config from "./config"; import { queryClient } from "./graphql"; import { routeTree } from "./routeTree.gen"; @@ -13,6 +14,7 @@ import { routeTree } from "./routeTree.gen"; export const router = createRouter({ routeTree, basepath: config.root, + defaultPendingComponent: LoadingScreen, defaultPreload: "intent", context: { queryClient }, }); diff --git a/frontend/src/routes/_account.index.lazy.tsx b/frontend/src/routes/_account.index.lazy.tsx index 7a00089ce..fe63e0906 100644 --- a/frontend/src/routes/_account.index.lazy.tsx +++ b/frontend/src/routes/_account.index.lazy.tsx @@ -10,12 +10,11 @@ import { notFound, useNavigate, } from "@tanstack/react-router"; -import { Alert, Heading, Separator, Text } from "@vector-im/compound-web"; +import { Alert, Separator, Text } from "@vector-im/compound-web"; import { Suspense } from "react"; import { useTranslation } from "react-i18next"; import AccountManagementPasswordPreview from "../components/AccountManagementPasswordPreview"; -import BlockList from "../components/BlockList"; import { ButtonLink } from "../components/ButtonLink"; import * as Collapsible from "../components/Collapsible"; import LoadingSpinner from "../components/LoadingSpinner"; @@ -43,64 +42,55 @@ function Index(): React.ReactElement { }; return ( - <> - - {/* This wrapper is only needed for the anchor link */} -
- {viewer.primaryEmail ? ( - - ) : ( - - )} - - }> - - +
+ + {viewer.primaryEmail ? ( + + ) : ( + + )} - {siteConfig.emailChangeAllowed && ( - - )} -
+ }> + + - {siteConfig.passwordLoginEnabled && ( - <> - + {siteConfig.emailChangeAllowed && ( + + )} + + {siteConfig.passwordLoginEnabled && ( + <> + + - - )} + + + )} - + - - - - {t("common.e2ee")} - - - - - - {t("frontend.reset_cross_signing.description")} - - - {t("frontend.reset_cross_signing.start_reset")} - - - - - - + + + {t("frontend.reset_cross_signing.description")} + + + {t("frontend.reset_cross_signing.start_reset")} + + +
); } diff --git a/frontend/src/routes/_account.lazy.tsx b/frontend/src/routes/_account.lazy.tsx index 698755cc4..d5390874d 100644 --- a/frontend/src/routes/_account.lazy.tsx +++ b/frontend/src/routes/_account.lazy.tsx @@ -33,23 +33,25 @@ function Account(): React.ReactElement { return ( -
-
- +
+
+ {t("frontend.account.title")}
- +
+ - + - - {t("frontend.nav.settings")} - {t("frontend.nav.devices")} - + + {t("frontend.nav.settings")} + {t("frontend.nav.devices")} + +
diff --git a/frontend/src/routes/password.recovery.index.lazy.tsx b/frontend/src/routes/password.recovery.index.lazy.tsx index 85297847b..3b16d6d1a 100644 --- a/frontend/src/routes/password.recovery.index.lazy.tsx +++ b/frontend/src/routes/password.recovery.index.lazy.tsx @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -7,20 +7,23 @@ import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; import { createLazyFileRoute, - useRouter, + notFound, + useNavigate, useSearch, } from "@tanstack/react-router"; +import IconError from "@vector-im/compound-design-tokens/assets/web/icons/error"; import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid"; -import { Alert, Form } from "@vector-im/compound-web"; +import { Alert, Button, Form } from "@vector-im/compound-web"; import type { FormEvent } from "react"; import { useTranslation } from "react-i18next"; import BlockList from "../components/BlockList"; +import { ButtonLink } from "../components/ButtonLink"; import Layout from "../components/Layout"; import LoadingSpinner from "../components/LoadingSpinner"; import PageHeading from "../components/PageHeading"; import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput"; -import { graphql } from "../gql"; +import { type FragmentType, graphql, useFragment } from "../gql"; import { graphqlRequest } from "../graphql"; import { translateSetPasswordError } from "../i18n/password_changes"; import { query } from "./password.recovery.index"; @@ -35,19 +38,123 @@ const RECOVER_PASSWORD_MUTATION = graphql(/* GraphQL */ ` } `); -export const Route = createLazyFileRoute("/password/recovery/")({ - component: RecoverPassword, -}); +const RESEND_EMAIL_MUTATION = graphql(/* GraphQL */ ` + mutation ResendRecoveryEmail($ticket: String!) { + resendRecoveryEmail(input: { ticket: $ticket }) { + status + progressUrl + } + } +`); -function RecoverPassword(): React.ReactNode { +const FRAGMENT = graphql(/* GraphQL */ ` + fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket { + username + email + } +`); + +const SITE_CONFIG_FRAGMENT = graphql(/* GraphQL */ ` + fragment RecoverPassword_siteConfig on SiteConfig { + ...PasswordCreationDoubleInput_siteConfig + } +`); + +const EmailConsumed: React.FC = () => { const { t } = useTranslation(); - const { ticket } = useSearch({ - from: "/password/recovery/", + return ( + + + + + {t("action.start_over")} + + + ); +}; + +const EmailExpired: React.FC<{ + userRecoveryTicket: FragmentType; + ticket: string; +}> = (props) => { + const { t } = useTranslation(); + const userRecoveryTicket = useFragment(FRAGMENT, props.userRecoveryTicket); + + const mutation = useMutation({ + mutationFn: async ({ ticket }: { ticket: string }) => { + const response = await graphqlRequest({ + query: RESEND_EMAIL_MUTATION, + variables: { + ticket, + }, + }); + + if (response.resendRecoveryEmail.status === "SENT") { + if (!response.resendRecoveryEmail.progressUrl) { + throw new Error("Unexpected response, missing progress URL"); + } + + // Redirect to the URL which confirms that the email was sent + window.location.href = response.resendRecoveryEmail.progressUrl; + + // We await an infinite promise here, so that the mutation + // doesn't resolve + await new Promise(() => undefined); + } + + return response.resendRecoveryEmail; + }, }); - const { - data: { siteConfig }, - } = useSuspenseQuery(query); - const router = useRouter(); + + const onClick = (event: React.MouseEvent): void => { + event.preventDefault(); + mutation.mutate({ ticket: props.ticket }); + }; + + return ( + + + + {mutation.data?.status === "RATE_LIMITED" && ( + + )} + + + + + {t("action.start_over")} + + + ); +}; + +const EmailRecovery: React.FC<{ + siteConfig: FragmentType; + userRecoveryTicket: FragmentType; + ticket: string; +}> = (props) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const siteConfig = useFragment(SITE_CONFIG_FRAGMENT, props.siteConfig); + const userRecoveryTicket = useFragment(FRAGMENT, props.userRecoveryTicket); const mutation = useMutation({ mutationFn: async ({ @@ -76,8 +183,7 @@ function RecoverPassword(): React.ReactNode { // The MAS backend will then redirect to the login page // Unfortunately this won't work in dev mode (`npm run dev`) // as the backend isn't involved there. - const location = router.buildLocation({ to: "/" }); - window.location.href = location.href; + await navigate({ to: "/", reloadDocument: true }); } return response.setPasswordByRecovery; @@ -88,10 +194,10 @@ function RecoverPassword(): React.ReactNode { event.preventDefault(); const form = new FormData(event.currentTarget); - mutation.mutate({ ticket, form }); + mutation.mutate({ ticket: props.ticket, form }); }; - const unhandleableError = mutation.error !== undefined; + const unhandleableError = mutation.error !== null; const errorMsg: string | undefined = translateSetPasswordError( t, @@ -104,7 +210,7 @@ function RecoverPassword(): React.ReactNode { @@ -131,6 +237,13 @@ function RecoverPassword(): React.ReactNode { )} + + ); +}; + +export const Route = createLazyFileRoute("/password/recovery/")({ + component: RecoverPassword, +}); + +function RecoverPassword(): React.ReactNode { + const { ticket } = useSearch({ + from: "/password/recovery/", + }); + const { + data: { siteConfig, userRecoveryTicket }, + } = useSuspenseQuery(query(ticket)); + + if (!userRecoveryTicket) { + throw notFound(); + } + + switch (userRecoveryTicket.status) { + case "EXPIRED": + return ( + + ); + case "CONSUMED": + return ; + case "VALID": + return ( + + ); + default: { + const exhaustiveCheck: never = userRecoveryTicket.status; + throw new Error(`Unhandled case: ${exhaustiveCheck}`); + } + } } diff --git a/frontend/src/routes/password.recovery.index.tsx b/frontend/src/routes/password.recovery.index.tsx index 81bcfc175..ba1a43df0 100644 --- a/frontend/src/routes/password.recovery.index.tsx +++ b/frontend/src/routes/password.recovery.index.tsx @@ -1,28 +1,35 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. import { queryOptions } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, notFound } from "@tanstack/react-router"; import { zodSearchValidator } from "@tanstack/router-zod-adapter"; import * as z from "zod"; import { graphql } from "../gql"; import { graphqlRequest } from "../graphql"; const QUERY = graphql(/* GraphQL */ ` - query PasswordRecovery { + query PasswordRecovery($ticket: String!) { siteConfig { - ...PasswordCreationDoubleInput_siteConfig + ...RecoverPassword_siteConfig + } + + userRecoveryTicket(ticket: $ticket) { + status + ...RecoverPassword_userRecoveryTicket } } `); -export const query = queryOptions({ - queryKey: ["passwordRecovery"], - queryFn: ({ signal }) => graphqlRequest({ query: QUERY, signal }), -}); +export const query = (ticket: string) => + queryOptions({ + queryKey: ["passwordRecovery", ticket], + queryFn: ({ signal }) => + graphqlRequest({ query: QUERY, signal, variables: { ticket } }), + }); const schema = z.object({ ticket: z.string(), @@ -31,5 +38,15 @@ const schema = z.object({ export const Route = createFileRoute("/password/recovery/")({ validateSearch: zodSearchValidator(schema), - loader: ({ context }) => context.queryClient.ensureQueryData(query), + loaderDeps: ({ search: { ticket } }) => ({ ticket }), + + async loader({ context, deps: { ticket } }): Promise { + const { userRecoveryTicket } = await context.queryClient.ensureQueryData( + query(ticket), + ); + + if (!userRecoveryTicket) { + throw notFound(); + } + }, }); diff --git a/frontend/src/routes/reset-cross-signing.cancelled.tsx b/frontend/src/routes/reset-cross-signing.cancelled.tsx index 756b8cd97..16be2dc27 100644 --- a/frontend/src/routes/reset-cross-signing.cancelled.tsx +++ b/frontend/src/routes/reset-cross-signing.cancelled.tsx @@ -17,7 +17,7 @@ export const Route = createFileRoute("/reset-cross-signing/cancelled")({ {t("frontend.reset_cross_signing.cancelled.description_1")} diff --git a/frontend/src/routes/reset-cross-signing.tsx b/frontend/src/routes/reset-cross-signing.tsx index d1f55bc7b..2af990b6b 100644 --- a/frontend/src/routes/reset-cross-signing.tsx +++ b/frontend/src/routes/reset-cross-signing.tsx @@ -39,7 +39,7 @@ function ResetCrossSigningError({ }: ErrorComponentProps): React.ReactElement { const { t } = useTranslation(); return ( - <> + reset()}> {t("action.back")} - + ); } diff --git a/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap b/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap index cec1375d4..19666f3b4 100644 --- a/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap +++ b/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap @@ -12,7 +12,7 @@ exports[`Reset cross signing > renders the cancelled page 1`] = ` class="_pageHeading_c10486" >
renders the deep link page 1`] = ` exports[`Reset cross signing > renders the errored page 1`] = ` -
-
- - - -
-
+ + +
+
+

+ Failed to allow crypto identity reset +

+
+
+

+ This might be a temporary problem, so please try again later. If the problem persists, please contact your server administrator. +

+ +
- -

- This might be a temporary problem, so please try again later. If the problem persists, please contact your server administrator. -

- + All Rights Reserved. The Super Chat name, logo and device are registered trade marks of BigCorp Ltd. +

+ + `; diff --git a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap index c89b52f57..7bbdb9738 100644 --- a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap +++ b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap @@ -2,18 +2,18 @@ exports[`Account home page > display name edit box > displays an error if the display name is invalid 1`] = ` This is what others will see wherever you’re signed in. @@ -236,13 +236,13 @@ exports[`Account home page > display name edit box > lets edit the display name > display name edit box > lets edit the display name Cancel
- - A -
-

- Alice -

- -
- -
- +
-
-
- -
+
- - Choose a different primary email to delete this one. - +
+ + + +
+
-
- -
+
+ +
+ +
+ + Choose a different primary email to delete this one. + +
+
+
- + +
+ +
+ + Add an alternative email you can use to access this account. +
- - Add an alternative email you can use to access this account. - -
- -
+ + + - + + + + + Change password + + + + + +