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..540e665b5 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,50 @@ impl UserMutations { status: SetPasswordStatus::Allowed, }) } + + /// Resend a user recovery email + 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/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/frontend/locales/en.json b/frontend/locales/en.json index cdd48a779..de907ade9 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -7,7 +7,8 @@ "continue": "Continue", "edit": "Edit", "save": "Save", - "save_and_continue": "Save and continue" + "save_and_continue": "Save and continue", + "start_over": "Start over" }, "branding": { "privacy_policy": { @@ -84,7 +85,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 +139,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..807e6acbe 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -804,6 +804,12 @@ type Mutation { """ setPasswordByRecovery(input: SetPasswordByRecoveryInput!): SetPasswordPayload! """ + Resend a user recovery email + """ + resendRecoveryEmail( + input: ResendRecoveryEmailInput! + ): ResendRecoveryEmailPayload! + """ Create a new arbitrary OAuth 2.0 Session. Only available for administrators. @@ -1026,6 +1032,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 +1171,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 +2075,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/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..93ecff4e7 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -493,6 +493,8 @@ export type Mutation = { lockUser: LockUserPayload; /** Remove an email address */ removeEmail: RemoveEmailPayload; + /** Resend a user recovery email */ + resendRecoveryEmail: ResendRecoveryEmailPayload; /** Send a verification code for an email address */ sendVerificationEmail: SendVerificationEmailPayload; /** @@ -576,6 +578,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 +770,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 +861,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 +903,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 +1428,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 +1662,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 +1772,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 +1908,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 +2028,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 +2582,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 +3143,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 +3172,9 @@ export const mockRecoverPasswordMutation = (resolver: GraphQLResponseResolver { + * const { ticket } = variables; * return HttpResponse.json({ - * data: { siteConfig } + * data: { siteConfig, userRecoveryTicket } * }) * }, * requestOptions 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/vite.config.ts b/frontend/vite.config.ts index 92b2b61b4..c62612d09 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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 @@ -146,7 +146,7 @@ export default defineConfig((env) => ({ base: "/account/", proxy: { // Routes mostly extracted from crates/router/src/endpoints.rs - "^/(|graphql.*|assets.*|\\.well-known.*|oauth2.*|login.*|logout.*|register.*|reauth.*|add-email.*|verify-email.*|change-password.*|consent.*|_matrix.*|complete-compat-sso.*|link.*|device.*|upstream.*)$": + "^/(|graphql.*|assets.*|\\.well-known.*|oauth2.*|login.*|logout.*|register.*|reauth.*|add-email.*|verify-email.*|change-password.*|consent.*|_matrix.*|complete-compat-sso.*|link.*|device.*|upstream.*|recover.*)$": "http://127.0.0.1:8080", }, },