mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-05-06 04:56:18 +00:00
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:
@@ -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>),
|
||||
|
||||
@@ -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>),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user