Polish the password recovery page

This includes:

 - show an error message if the recovery link is expired, with a button
   to resend the email
 - show an error message if the recovery link has already been used
 - include an invisible username field in the form, so that password
   managers can save the new password
This commit is contained in:
Quentin Gliech
2025-01-07 17:08:43 +01:00
parent 5cbb576f94
commit 4ca76be866
13 changed files with 729 additions and 57 deletions
+3 -2
View File
@@ -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<CompatSession>),
BrowserSession(Box<BrowserSession>),
UserEmail(Box<UserEmail>),
UserRecoveryTicket(Box<UserRecoveryTicket>),
UpstreamOAuth2Provider(Box<UpstreamOAuth2Provider>),
UpstreamOAuth2Link(Box<UpstreamOAuth2Link>),
OAuth2Session(Box<OAuth2Session>),
+6 -1
View File
@@ -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<UpstreamOAuth2Link>),
User(Box<User>),
UserEmail(Box<UserEmail>),
UserRecoveryTicket(Box<UserRecoveryTicket>),
}
+99 -1
View File
@@ -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<Utc> {
self.0.created_at
}
/// The status of the ticket
pub async fn status(
&self,
context: &Context<'_>,
) -> Result<UserRecoveryTicketStatus, async_graphql::Error> {
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<String, async_graphql::Error> {
// 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<String, async_graphql::Error> {
// 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)
}
}
+112 -2
View File
@@ -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<Url> {
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<ResendRecoveryEmailPayload, async_graphql::Error> {
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,
})
}
}
+19 -3
View File
@@ -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<Option<UserRecoveryTicket>, 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<Option<Node>, 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)
+3 -1
View File
@@ -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<S>,
Encrypter: FromRef<S>,
CookieJar: FromRequestParts<S>,
Limiter: FromRef<S>,
RequesterFingerprint: FromRequestParts<S>,
{
let mut router = Router::new()
.route(
+14 -2
View File
@@ -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": {
+100
View File
@@ -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.
"""
+17 -2
View File
@@ -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.
*/
+158 -13
View File
@@ -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<User>;
/** Fetch a user email by its ID. */
userEmail?: Maybe<UserEmail>;
/** Fetch a user recovery ticket. */
userRecoveryTicket?: Maybe<UserRecoveryTicket>;
/**
* 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<Scalars['String']['input']>;
@@ -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<Scalars['Url']['output']>;
/** 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<Scalars['Int']['input']>;
@@ -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<OAuth2Session_SessionFragment, unknown>;
export const PasswordCreationDoubleInput_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {
id
minimumPasswordComplexity
}
`, {"fragmentName":"PasswordCreationDoubleInput_siteConfig"}) as unknown as TypedDocumentString<PasswordCreationDoubleInput_SiteConfigFragment, unknown>;
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<UserEmail_VerifyEmailFragment, unknown>;
export const RecoverPassword_UserRecoveryTicketFragmentDoc = new TypedDocumentString(`
fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {
username
email
}
`, {"fragmentName":"RecoverPassword_userRecoveryTicket"}) as unknown as TypedDocumentString<RecoverPassword_UserRecoveryTicketFragment, unknown>;
export const PasswordCreationDoubleInput_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {
id
minimumPasswordComplexity
}
`, {"fragmentName":"PasswordCreationDoubleInput_siteConfig"}) as unknown as TypedDocumentString<PasswordCreationDoubleInput_SiteConfigFragment, unknown>;
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<RecoverPassword_SiteConfigFragment, unknown>;
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<RecoverPasswordMutation, RecoverPasswordMutationVariables>;
export const ResendRecoveryEmailDocument = new TypedDocumentString(`
mutation ResendRecoveryEmail($ticket: String!) {
resendRecoveryEmail(input: {ticket: $ticket}) {
status
progressUrl
}
}
`) as unknown as TypedDocumentString<ResendRecoveryEmailMutation, ResendRecoveryEmailMutationVariables>;
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<PasswordRecoveryQuery, PasswordRecoveryQueryVariables>;
export const AllowCrossSigningResetDocument = new TypedDocumentString(`
mutation AllowCrossSigningReset($userId: ID!) {
@@ -3027,6 +3149,28 @@ export const mockRecoverPasswordMutation = (resolver: GraphQLResponseResolver<Re
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))
* @see https://mswjs.io/docs/basics/response-resolver
* @example
* mockResendRecoveryEmailMutation(
* ({ query, variables }) => {
* const { ticket } = variables;
* return HttpResponse.json({
* data: { resendRecoveryEmail }
* })
* },
* requestOptions
* )
*/
export const mockResendRecoveryEmailMutation = (resolver: GraphQLResponseResolver<ResendRecoveryEmailMutation, ResendRecoveryEmailMutationVariables>, options?: RequestHandlerOptions) =>
graphql.mutation<ResendRecoveryEmailMutation, ResendRecoveryEmailMutationVariables>(
'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<Re
* @example
* mockPasswordRecoveryQuery(
* ({ query, variables }) => {
* const { ticket } = variables;
* return HttpResponse.json({
* data: { siteConfig }
* data: { siteConfig, userRecoveryTicket }
* })
* },
* requestOptions
@@ -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 (
<Layout>
<PageHeading
Icon={IconError}
title={t("frontend.password_reset.consumed.title")}
subtitle={t("frontend.password_reset.consumed.subtitle")}
invalid
/>
<ButtonLink kind="secondary" to="/" reloadDocument>
{t("action.start_over")}
</ButtonLink>
</Layout>
);
};
const EmailExpired: React.FC<{
userRecoveryTicket: FragmentType<typeof FRAGMENT>;
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<HTMLButtonElement>): void => {
event.preventDefault();
mutation.mutate({ ticket: props.ticket });
};
return (
<Layout>
<PageHeading
Icon={IconError}
title={t("frontend.password_reset.expired.title")}
subtitle={t("frontend.password_reset.expired.subtitle", {
email: userRecoveryTicket.email,
})}
invalid
/>
{mutation.data?.status === "RATE_LIMITED" && (
<Alert
type="critical"
title={t("frontend.errors.rate_limit_exceeded")}
/>
)}
<Button kind="primary" disabled={mutation.isPending} onClick={onClick}>
{!!mutation.isPending && <LoadingSpinner inline />}
{t("frontend.password_reset.expired.resend_email")}
</Button>
<ButtonLink kind="secondary" to="/" reloadDocument>
{t("action.start_over")}
</ButtonLink>
</Layout>
);
};
const EmailRecovery: React.FC<{
siteConfig: FragmentType<typeof SITE_CONFIG_FRAGMENT>;
userRecoveryTicket: FragmentType<typeof FRAGMENT>;
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 {
<PageHeading
Icon={IconLockSolid}
title={t("frontend.password_reset.title")}
subtitle={t("frontend.password_change.subtitle")}
subtitle={t("frontend.password_reset.subtitle")}
/>
<Form.Root onSubmit={onSubmit} method="POST">
@@ -131,6 +237,13 @@ function RecoverPassword(): React.ReactNode {
</Alert>
)}
<input
type="hidden"
name="username"
autoComplete="username"
value={userRecoveryTicket.username}
/>
<PasswordCreationDoubleInput
siteConfig={siteConfig}
forceShowNewPasswordInvalid={
@@ -146,4 +259,42 @@ function RecoverPassword(): React.ReactNode {
</BlockList>
</Layout>
);
};
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 (
<EmailExpired ticket={ticket} userRecoveryTicket={userRecoveryTicket} />
);
case "CONSUMED":
return <EmailConsumed />;
case "VALID":
return (
<EmailRecovery
ticket={ticket}
siteConfig={siteConfig}
userRecoveryTicket={userRecoveryTicket}
/>
);
default: {
const exhaustiveCheck: never = userRecoveryTicket.status;
throw new Error(`Unhandled case: ${exhaustiveCheck}`);
}
}
}
@@ -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<void> {
const { userRecoveryTicket } = await context.queryClient.ensureQueryData(
query(ticket),
);
if (!userRecoveryTicket) {
throw notFound();
}
},
});
+2 -2
View File
@@ -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",
},
},