Merge branch 'main' into dependabot/npm_and_yarn/frontend/graphql-codegen-97975e453b

This commit is contained in:
Quentin Gliech
2025-03-10 10:27:46 +01:00
committed by GitHub
24 changed files with 843 additions and 362 deletions
Generated
+7 -7
View File
@@ -742,9 +742,9 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bitflags"
version = "2.8.0"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
dependencies = [
"serde",
]
@@ -1120,9 +1120,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "convert_case"
version = "0.7.1"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
dependencies = [
"unicode-segmentation",
]
@@ -3034,7 +3034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.48.5",
"windows-targets 0.52.6",
]
[[package]]
@@ -6773,9 +6773,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.14.0"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93d59ca99a559661b96bf898d8fce28ed87935fd2bea9f05983c1464dd6c71b1"
checksum = "e0f540e3240398cce6128b64ba83fdbdd86129c16a3aa1a3a252efd66eb3d587"
dependencies = [
"serde",
]
+1 -1
View File
@@ -93,7 +93,7 @@ version = "1.6.0"
# Packed bitfields
[workspace.dependencies.bitflags]
version = "2.8.0"
version = "2.9.0"
# Bytes
[workspace.dependencies.bytes]
@@ -6,18 +6,75 @@
use anyhow::Context as _;
use async_graphql::{Context, Description, Enum, ID, InputObject, Object};
use mas_data_model::SiteConfig;
use mas_i18n::DataLocale;
use mas_storage::{
RepositoryAccess,
BoxRepository, RepositoryAccess,
queue::{ProvisionUserJob, QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob},
user::{UserEmailFilter, UserEmailRepository, UserRepository},
};
use zeroize::Zeroizing;
use crate::graphql::{
model::{NodeType, User, UserEmail, UserEmailAuthentication},
state::ContextExt,
use crate::{
graphql::{
Requester,
model::{NodeType, User, UserEmail, UserEmailAuthentication},
state::ContextExt,
},
passwords::PasswordManager,
};
/// Check the password if neeed
///
/// Returns true if password verification is not needed, or if the password is
/// correct. Returns false if the password is incorrect or missing.
async fn verify_password_if_needed(
requester: &Requester,
config: &SiteConfig,
password_manager: &PasswordManager,
password: Option<String>,
user: &mas_data_model::User,
repo: &mut BoxRepository,
) -> Result<bool, async_graphql::Error> {
// If the requester is admin, they don't need to provide a password
if requester.is_admin() {
return Ok(true);
}
// If password login is disabled, assume we don't want the user to reauth
if !config.password_login_enabled {
return Ok(true);
}
// Else we need to check if the user has a password
let Some(user_password) = repo
.user_password()
.active(user)
.await
.context("Failed to load user password")?
else {
// User has no password, so we don't need to verify the password
return Ok(true);
};
let Some(password) = password else {
// There is a password on the user, but not provided in the input
return Ok(false);
};
let password = Zeroizing::new(password.into_bytes());
let res = password_manager
.verify(
user_password.version,
password,
user_password.hashed_password,
)
.await;
Ok(res.is_ok())
}
#[derive(Default)]
pub struct UserEmailMutations {
_private: (),
@@ -120,6 +177,10 @@ impl AddEmailPayload {
struct RemoveEmailInput {
/// The ID of the email address to remove
user_email_id: ID,
/// The user's current password. This is required if the user is not an
/// admin and it has a password on its account.
password: Option<String>,
}
/// The status of the `removeEmail` mutation
@@ -130,6 +191,9 @@ enum RemoveEmailStatus {
/// The email address was not found
NotFound,
/// The password provided is incorrect
IncorrectPassword,
}
/// The payload of the `removeEmail` mutation
@@ -137,6 +201,7 @@ enum RemoveEmailStatus {
enum RemoveEmailPayload {
Removed(mas_data_model::UserEmail),
NotFound,
IncorrectPassword,
}
#[Object(use_type_description)]
@@ -146,6 +211,7 @@ impl RemoveEmailPayload {
match self {
RemoveEmailPayload::Removed(_) => RemoveEmailStatus::Removed,
RemoveEmailPayload::NotFound => RemoveEmailStatus::NotFound,
RemoveEmailPayload::IncorrectPassword => RemoveEmailStatus::IncorrectPassword,
}
}
@@ -153,20 +219,23 @@ impl RemoveEmailPayload {
async fn email(&self) -> Option<UserEmail> {
match self {
RemoveEmailPayload::Removed(email) => Some(UserEmail(email.clone())),
RemoveEmailPayload::NotFound => None,
RemoveEmailPayload::NotFound | RemoveEmailPayload::IncorrectPassword => None,
}
}
/// The user to whom the email address belonged
async fn user(&self, ctx: &Context<'_>) -> Result<Option<User>, async_graphql::Error> {
let state = ctx.state();
let mut repo = state.repository().await?;
let user_id = match self {
RemoveEmailPayload::Removed(email) => email.user_id,
RemoveEmailPayload::NotFound => return Ok(None),
RemoveEmailPayload::NotFound | RemoveEmailPayload::IncorrectPassword => {
return Ok(None);
}
};
let mut repo = state.repository().await?;
let user = repo
.user()
.lookup(user_id)
@@ -226,6 +295,10 @@ struct StartEmailAuthenticationInput {
/// The email address to add to the account
email: String,
/// The user's current password. This is required if the user has a password
/// on its account.
password: Option<String>,
/// The language to use for the email
#[graphql(default = "en")]
language: String,
@@ -244,6 +317,8 @@ enum StartEmailAuthenticationStatus {
Denied,
/// The email address is already in use on this account
InUse,
/// The password provided is incorrect
IncorrectPassword,
}
/// The payload of the `startEmailAuthentication` mutation
@@ -256,6 +331,7 @@ enum StartEmailAuthenticationPayload {
violations: Vec<mas_policy::Violation>,
},
InUse,
IncorrectPassword,
}
#[Object(use_type_description)]
@@ -268,6 +344,7 @@ impl StartEmailAuthenticationPayload {
Self::RateLimited => StartEmailAuthenticationStatus::RateLimited,
Self::Denied { .. } => StartEmailAuthenticationStatus::Denied,
Self::InUse => StartEmailAuthenticationStatus::InUse,
Self::IncorrectPassword => StartEmailAuthenticationStatus::IncorrectPassword,
}
}
@@ -275,9 +352,11 @@ impl StartEmailAuthenticationPayload {
async fn authentication(&self) -> Option<&UserEmailAuthentication> {
match self {
Self::Started(authentication) => Some(authentication),
Self::InvalidEmailAddress | Self::RateLimited | Self::Denied { .. } | Self::InUse => {
None
}
Self::InvalidEmailAddress
| Self::RateLimited
| Self::Denied { .. }
| Self::InUse
| Self::IncorrectPassword => None,
}
}
@@ -494,6 +573,20 @@ impl UserEmailMutations {
.await?
.context("Failed to load user")?;
// Validate the password input if needed
if !verify_password_if_needed(
requester,
state.site_config(),
&state.password_manager(),
input.password,
&user,
&mut repo,
)
.await?
{
return Ok(RemoveEmailPayload::IncorrectPassword);
}
// TODO: don't allow removing the last email address
repo.user_email().remove(user_email.clone()).await?;
@@ -627,6 +720,20 @@ impl UserEmailMutations {
});
}
// Validate the password input if needed
if !verify_password_if_needed(
requester,
state.site_config(),
&state.password_manager(),
input.password,
&browser_session.user,
&mut repo,
)
.await?
{
return Ok(StartEmailAuthenticationPayload::IncorrectPassword);
}
// Create a new authentication session
let authentication = repo
.user_email()
+1 -1
View File
@@ -15,7 +15,7 @@ workspace = true
anyhow.workspace = true
async-trait.workspace = true
camino.workspace = true
convert_case = "0.7.1"
convert_case = "0.8.0"
csv = "1.3.1"
reqwest.workspace = true
serde.workspace = true
+1 -1
View File
@@ -26,7 +26,7 @@ opentelemetry-semantic-conventions.workspace = true
rand.workspace = true
rand_chacha.workspace = true
url.workspace = true
uuid = "1.14.0"
uuid = "1.15.1"
ulid = { workspace = true, features = ["uuid"] }
oauth2-types.workspace = true
+1 -1
View File
@@ -25,7 +25,7 @@ tracing.workspace = true
futures-util = "0.3.31"
rand.workspace = true
uuid = "1.14.0"
uuid = "1.15.1"
ulid = { workspace = true, features = ["uuid"] }
mas-config.workspace = true
+4 -3
View File
@@ -172,14 +172,15 @@ const MAX_CONCURRENT_JOBS: usize = 10;
const MAX_JOBS_TO_FETCH: usize = 5;
// How many attempts a job should be retried
const MAX_ATTEMPTS: usize = 5;
const MAX_ATTEMPTS: usize = 10;
/// Returns the delay to wait before retrying a job
///
/// Uses an exponential backoff: 1s, 2s, 4s, 8s, 16s
/// Uses an exponential backoff: 5s, 10s, 20s, 40s, 1m20s, 2m40s, 5m20s, 10m50s,
/// 21m40s, 43m20s
fn retry_delay(attempt: usize) -> Duration {
let attempt = u32::try_from(attempt).unwrap_or(u32::MAX);
Duration::milliseconds(2_i64.saturating_pow(attempt) * 1000)
Duration::milliseconds(2_i64.saturating_pow(attempt) * 5_000)
}
type JobResult = Result<(), JobError>;
+8
View File
@@ -14,6 +14,14 @@ ignore = [
# RSA key extraction "Marvin Attack". This is only relevant when using
# PKCS#1 v1.5 encryption, which we don't
"RUSTSEC-2023-0071",
# `paste`, as used by `aws-lc-rs` is unmaintained, but we're not concerned
# about it having a security vulnerability
"RUSTSEC-2024-0436",
# rust-protobuf has an infinite recursion issue when parsing inputs. We only
# use protobuf for opentelemetry output, so we are not affected
"RUSTSEC-2024-0437",
]
[licenses]
+8 -2
View File
@@ -5,6 +5,7 @@
"clear": "Clear",
"close": "Close",
"collapse": "Collapse",
"confirm": "Confirm",
"continue": "Continue",
"edit": "Edit",
"expand": "Expand",
@@ -27,6 +28,7 @@
"e2ee": "End-to-end encryption",
"loading": "Loading…",
"next": "Next",
"password": "Password",
"previous": "Previous",
"saved": "Saved",
"saving": "Saving…"
@@ -57,7 +59,9 @@
"email_field_help": "Add an alternative email you can use to access this account.",
"email_field_label": "Add email",
"email_in_use_error": "The entered email is already in use",
"email_invalid_error": "The entered email is invalid"
"email_invalid_error": "The entered email is invalid",
"incorrect_password_error": "Incorrect password, please try again",
"password_confirmation": "Confirm your account password to add this email address"
},
"browser_session_details": {
"current_badge": "Current"
@@ -258,7 +262,9 @@
"user_email": {
"delete_button_confirmation_modal": {
"action": "Delete email",
"body": "Delete this email?"
"body": "Delete this email?",
"incorrect_password": "Incorrect password, please try again",
"password_confirmation": "Confirm your account password to delete this email address"
},
"delete_button_title": "Remove email address",
"email": "Email"
+91 -91
View File
@@ -12,7 +12,7 @@
"@fontsource/inter": "^5.2.5",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query": "^5.67.2",
"@tanstack/react-router": "^1.112.18",
"@vector-im/compound-design-tokens": "4.0.1",
"@vector-im/compound-web": "^7.6.4",
@@ -42,9 +42,9 @@
"@storybook/react": "^8.6.4",
"@storybook/react-vite": "^8.6.4",
"@storybook/test": "^8.5.5",
"@tanstack/react-query-devtools": "^5.67.1",
"@tanstack/react-query-devtools": "^5.67.2",
"@tanstack/router-devtools": "^1.112.18",
"@tanstack/router-vite-plugin": "^1.112.18",
"@tanstack/router-vite-plugin": "^1.112.19",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
@@ -53,7 +53,7 @@
"@types/react-dom": "19.0.4",
"@types/swagger-ui-dist": "^3.30.5",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.7",
"@vitest/coverage-v8": "^3.0.8",
"autoprefixer": "^10.4.20",
"browserslist-to-esbuild": "^2.1.1",
"graphql": "^16.10.0",
@@ -5328,9 +5328,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.67.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.67.1.tgz",
"integrity": "sha512-AkFmuukVejyqVIjEQoFhLb3q+xHl7JG8G9cANWTMe3s8iKzD9j1VBSYXgCjy6vm6xM8cUCR9zP2yqWxY9pTWOA==",
"version": "5.67.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.67.2.tgz",
"integrity": "sha512-+iaFJ/pt8TaApCk6LuZ0WHS/ECVfTzrxDOEL9HH9Dayyb5OVuomLzDXeSaI2GlGT/8HN7bDGiRXDts3LV+u6ww==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5338,9 +5338,9 @@
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.65.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.65.0.tgz",
"integrity": "sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg==",
"version": "5.67.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.67.2.tgz",
"integrity": "sha512-O4QXFFd7xqp6EX7sdvc9tsVO8nm4lpWBqwpgjpVLW5g7IeOY6VnS/xvs/YzbRhBVkKTMaJMOUGU7NhSX+YGoNg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -5349,12 +5349,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.67.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.67.1.tgz",
"integrity": "sha512-fH5u4JLwB6A+wLFdi8wWBWAYoJV5deYif2OveJ26ktAWjU499uvVFS1wPWnyEyq5LvZX1MZInvv9QRaIZANRaQ==",
"version": "5.67.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.67.2.tgz",
"integrity": "sha512-6Sa+BVNJWhAV4QHvIqM73norNeGRWGC3ftN0Ix87cmMvI215I1wyJ44KUTt/9a0V9YimfGcg25AITaYVel71Og==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.67.1"
"@tanstack/query-core": "5.67.2"
},
"funding": {
"type": "github",
@@ -5365,20 +5365,20 @@
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.67.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.67.1.tgz",
"integrity": "sha512-a/2I8ORNalh+ek6Nyb9mEiq2u7vydjVMvaQz5ZieGq7r7DxgIFcPiMs4Ay0qkQvHfptESgXR5nImGTHmmt19yQ==",
"version": "5.67.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.67.2.tgz",
"integrity": "sha512-cmj2DxBc+/9btQ66n5xI8wTtAma2BLVa403K7zIYiguzJ/kV201jnGensYqJeu1Rd8uRMLLRM74jLVMLDWNRJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.65.0"
"@tanstack/query-devtools": "5.67.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query": "^5.67.2",
"react": "^18 || ^19"
}
},
@@ -5472,9 +5472,9 @@
}
},
"node_modules/@tanstack/router-generator": {
"version": "1.112.18",
"resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.112.18.tgz",
"integrity": "sha512-CsSnTu0NriD7XhiM4sFBiXdrnn0jTSnY7/vwrmGKIwoA6d7CUsSZSbNa8bUmxGFk221MA4yKajNWjvBWcD0gTg==",
"version": "1.112.19",
"resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.112.19.tgz",
"integrity": "sha512-JFYj2oAhUuho0B2oAJ37mlVvtt/18EJGE0aNpbWXKfcQihadMqFf4kz/KeYv7kHrMsCtq+n7e50oi5q1x9iYSg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5500,9 +5500,9 @@
}
},
"node_modules/@tanstack/router-plugin": {
"version": "1.112.18",
"resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.112.18.tgz",
"integrity": "sha512-CnAJVwoj9A7ZgTGG9wjynq3FmM2HfDeiKMP7a/sYoEybuJMfMoW+A9lgZe70Dbca55QYQOWdvxfMn3wYss1MPw==",
"version": "1.112.19",
"resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.112.19.tgz",
"integrity": "sha512-dT+yI/hpxyaW0It1QQfGUSVQzKt1ciVJdqQLuc/blsfqZ7tWIBpLP9fCT5v2c4PIUPkUW/LG1P6bktVukXVKVA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5513,7 +5513,7 @@
"@babel/traverse": "^7.26.8",
"@babel/types": "^7.26.8",
"@tanstack/router-core": "^1.112.18",
"@tanstack/router-generator": "^1.112.18",
"@tanstack/router-generator": "^1.112.19",
"@tanstack/router-utils": "^1.112.18",
"@tanstack/virtual-file-routes": "^1.99.0",
"@types/babel__core": "^7.20.5",
@@ -5591,13 +5591,13 @@
}
},
"node_modules/@tanstack/router-vite-plugin": {
"version": "1.112.18",
"resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.112.18.tgz",
"integrity": "sha512-ZSIyFNhzER0atr+0JnejKew5UBZ2TNvHy4C+mH//ax75z5LHY9tUrePoH9uTF+IAZYJcPR54CUCLjXujtHbywQ==",
"version": "1.112.19",
"resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.112.19.tgz",
"integrity": "sha512-es6s+u62wiai2QycxdGUurojKs2hVd7G+Yw5krXE+aKndW7b6EtwhP+O/X01A7AfZU84dDsnvX1AJYVd/VvC6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/router-plugin": "^1.112.18"
"@tanstack/router-plugin": "^1.112.19"
},
"engines": {
"node": ">=12"
@@ -5986,9 +5986,9 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.7.tgz",
"integrity": "sha512-Av8WgBJLTrfLOer0uy3CxjlVuWK4CzcLBndW1Nm2vI+3hZ2ozHututkfc7Blu1u6waeQ7J8gzPK/AsBRnWA5mQ==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.8.tgz",
"integrity": "sha512-y7SAKsQirsEJ2F8bulBck4DoluhI2EEgTimHd6EEUgJBGKy9tC25cpywh1MH4FvDGoG2Unt7+asVd1kj4qOSAw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6009,8 +6009,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "3.0.7",
"vitest": "3.0.7"
"@vitest/browser": "3.0.8",
"vitest": "3.0.8"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -6084,13 +6084,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.7.tgz",
"integrity": "sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.8.tgz",
"integrity": "sha512-n3LjS7fcW1BCoF+zWZxG7/5XvuYH+lsFg+BDwwAz0arIwHQJFUEsKBQ0BLU49fCxuM/2HSeBPHQD8WjgrxMfow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.0.7",
"@vitest/spy": "3.0.8",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@@ -6111,9 +6111,9 @@
}
},
"node_modules/@vitest/mocker/node_modules/@vitest/spy": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.7.tgz",
"integrity": "sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.8.tgz",
"integrity": "sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6157,13 +6157,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.7.tgz",
"integrity": "sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.8.tgz",
"integrity": "sha512-c7UUw6gEcOzI8fih+uaAXS5DwjlBaCJUo7KJ4VvJcjL95+DSR1kova2hFuRt3w41KZEFcOEiq098KkyrjXeM5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.0.7",
"@vitest/utils": "3.0.8",
"pathe": "^2.0.3"
},
"funding": {
@@ -6171,9 +6171,9 @@
}
},
"node_modules/@vitest/runner/node_modules/@vitest/pretty-format": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.7.tgz",
"integrity": "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.8.tgz",
"integrity": "sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6184,13 +6184,13 @@
}
},
"node_modules/@vitest/runner/node_modules/@vitest/utils": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.7.tgz",
"integrity": "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.8.tgz",
"integrity": "sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.0.7",
"@vitest/pretty-format": "3.0.8",
"loupe": "^3.1.3",
"tinyrainbow": "^2.0.0"
},
@@ -6199,13 +6199,13 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.7.tgz",
"integrity": "sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.8.tgz",
"integrity": "sha512-x8IlMGSEMugakInj44nUrLSILh/zy1f2/BgH0UeHpNyOocG18M9CWVIFBaXPt8TrqVZWmcPjwfG/ht5tnpba8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.0.7",
"@vitest/pretty-format": "3.0.8",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@@ -6214,9 +6214,9 @@
}
},
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.7.tgz",
"integrity": "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.8.tgz",
"integrity": "sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14363,9 +14363,9 @@
}
},
"node_modules/vite-node": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.7.tgz",
"integrity": "sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.8.tgz",
"integrity": "sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14438,19 +14438,19 @@
"license": "MIT"
},
"node_modules/vitest": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.7.tgz",
"integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.8.tgz",
"integrity": "sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "3.0.7",
"@vitest/mocker": "3.0.7",
"@vitest/pretty-format": "^3.0.7",
"@vitest/runner": "3.0.7",
"@vitest/snapshot": "3.0.7",
"@vitest/spy": "3.0.7",
"@vitest/utils": "3.0.7",
"@vitest/expect": "3.0.8",
"@vitest/mocker": "3.0.8",
"@vitest/pretty-format": "^3.0.8",
"@vitest/runner": "3.0.8",
"@vitest/snapshot": "3.0.8",
"@vitest/spy": "3.0.8",
"@vitest/utils": "3.0.8",
"chai": "^5.2.0",
"debug": "^4.4.0",
"expect-type": "^1.1.0",
@@ -14462,7 +14462,7 @@
"tinypool": "^1.0.2",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0",
"vite-node": "3.0.7",
"vite-node": "3.0.8",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -14478,8 +14478,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.0.7",
"@vitest/ui": "3.0.7",
"@vitest/browser": "3.0.8",
"@vitest/ui": "3.0.8",
"happy-dom": "*",
"jsdom": "*"
},
@@ -14508,14 +14508,14 @@
}
},
"node_modules/vitest/node_modules/@vitest/expect": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.7.tgz",
"integrity": "sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.8.tgz",
"integrity": "sha512-Xu6TTIavTvSSS6LZaA3EebWFr6tsoXPetOWNMOlc7LO88QVVBwq2oQWBoDiLCN6YTvNYsGSjqOO8CAdjom5DCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.0.7",
"@vitest/utils": "3.0.7",
"@vitest/spy": "3.0.8",
"@vitest/utils": "3.0.8",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@@ -14524,9 +14524,9 @@
}
},
"node_modules/vitest/node_modules/@vitest/pretty-format": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.7.tgz",
"integrity": "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.8.tgz",
"integrity": "sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14537,9 +14537,9 @@
}
},
"node_modules/vitest/node_modules/@vitest/spy": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.7.tgz",
"integrity": "sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.8.tgz",
"integrity": "sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14550,13 +14550,13 @@
}
},
"node_modules/vitest/node_modules/@vitest/utils": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.7.tgz",
"integrity": "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==",
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.8.tgz",
"integrity": "sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.0.7",
"@vitest/pretty-format": "3.0.8",
"loupe": "^3.1.3",
"tinyrainbow": "^2.0.0"
},
+4 -4
View File
@@ -22,7 +22,7 @@
"@fontsource/inter": "^5.2.5",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query": "^5.67.2",
"@tanstack/react-router": "^1.112.18",
"@vector-im/compound-design-tokens": "4.0.1",
"@vector-im/compound-web": "^7.6.4",
@@ -52,9 +52,9 @@
"@storybook/react": "^8.6.4",
"@storybook/react-vite": "^8.6.4",
"@storybook/test": "^8.5.5",
"@tanstack/react-query-devtools": "^5.67.1",
"@tanstack/react-query-devtools": "^5.67.2",
"@tanstack/router-devtools": "^1.112.18",
"@tanstack/router-vite-plugin": "^1.112.18",
"@tanstack/router-vite-plugin": "^1.112.19",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
@@ -63,7 +63,7 @@
"@types/react-dom": "19.0.4",
"@types/swagger-ui-dist": "^3.30.5",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.7",
"@vitest/coverage-v8": "^3.0.8",
"autoprefixer": "^10.4.20",
"browserslist-to-esbuild": "^2.1.1",
"graphql": "^16.10.0",
+18
View File
@@ -1203,6 +1203,11 @@ input RemoveEmailInput {
The ID of the email address to remove
"""
userEmailId: ID!
"""
The user's current password. This is required if the user is not an
admin and it has a password on its account.
"""
password: String
}
"""
@@ -1235,6 +1240,10 @@ enum RemoveEmailStatus {
The email address was not found
"""
NOT_FOUND
"""
The password provided is incorrect
"""
INCORRECT_PASSWORD
}
"""
@@ -1610,6 +1619,11 @@ input StartEmailAuthenticationInput {
"""
email: String!
"""
The user's current password. This is required if the user has a password
on its account.
"""
password: String
"""
The language to use for the email
"""
language: String! = "en"
@@ -1657,6 +1671,10 @@ enum StartEmailAuthenticationStatus {
The email address is already in use on this account
"""
IN_USE
"""
The password provided is incorrect
"""
INCORRECT_PASSWORD
}
"""
@@ -0,0 +1,105 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { Button, Form } from "@vector-im/compound-web";
import type React from "react";
import { useCallback, useImperativeHandle, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "./Dialog";
type ModalRef = {
prompt: () => Promise<string>;
};
type Props = {
title: string;
destructive?: boolean;
ref: React.Ref<ModalRef>;
};
/**
* A hook that returns a function that prompts the user to enter a password.
* The returned function returns a promise that resolves to the password, and
* throws an error if the user cancels the prompt.
*
* It also returns a ref that must be passed to a mounted Modal component.
*/
export const usePasswordConfirmation = (): [
() => Promise<string>,
React.RefObject<ModalRef>,
] => {
const ref = useRef<ModalRef>({
prompt: () => {
throw new Error("PasswordConfirmationModal is not mounted!");
},
});
const prompt = useCallback(() => ref.current.prompt(), []);
return [prompt, ref] as const;
};
const PasswordConfirmationModal: React.FC<Props> = ({
title,
destructive,
ref,
}) => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const resolversRef = useRef<PromiseWithResolvers<string>>(null);
useImperativeHandle(ref, () => ({
prompt: () => {
setOpen(true);
if (resolversRef.current === null) {
resolversRef.current = Promise.withResolvers();
}
return resolversRef.current.promise;
},
}));
const onOpenChange = useCallback((open: boolean) => {
setOpen(open);
if (!open) {
resolversRef.current?.reject(new Error("User cancelled password prompt"));
resolversRef.current = null;
}
}, []);
const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
const password = data.get("password");
if (typeof password !== "string") {
throw new Error(); // This should never happen
}
resolversRef.current?.resolve(password);
resolversRef.current = null;
setOpen(false);
}, []);
return (
<Dialog.Dialog open={open} onOpenChange={onOpenChange}>
<Dialog.Title>{title}</Dialog.Title>
<Form.Root onSubmit={onSubmit}>
<Form.Field name="password">
<Form.Label>{t("common.password")}</Form.Label>
<Form.PasswordControl autoFocus autoComplete="current-password" />
</Form.Field>
<Button type="submit" kind="primary" destructive={destructive}>
{t("action.confirm")}
</Button>
</Form.Root>
<Dialog.Close asChild>
<Button kind="tertiary">{t("action.cancel")}</Button>
</Dialog.Close>
</Dialog.Dialog>
);
};
export default PasswordConfirmationModal;
@@ -38,6 +38,7 @@ button[disabled] .user-email-delete-icon {
display: flex;
align-items: center;
gap: var(--cpd-space-4x);
border-radius: var(--cpd-space-4x);
border: 1px solid var(--cpd-color-gray-400);
padding: var(--cpd-space-3x);
font: var(--cpd-font-body-md-semibold);
+126 -80
View File
@@ -7,16 +7,25 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete";
import IconEmail from "@vector-im/compound-design-tokens/assets/web/icons/email";
import { Button, Form, IconButton, Tooltip } from "@vector-im/compound-web";
import type { ComponentProps, ReactNode } from "react";
import {
Button,
ErrorMessage,
Form,
IconButton,
Tooltip,
} from "@vector-im/compound-web";
import { type ReactNode, useCallback, useState } from "react";
import { Translation, useTranslation } from "react-i18next";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { graphqlRequest } from "../../graphql";
import { Close, Description, Dialog, Title } from "../Dialog";
import LoadingSpinner from "../LoadingSpinner";
import PasswordConfirmationModal, {
usePasswordConfirmation,
} from "../PasswordConfirmation";
import styles from "./UserEmail.module.css";
// This component shows a single user email address, with controls to verify it,
// resend the verification email, remove it, and set it as the primary email address.
// This component shows a single user email address, with controls to remove it
export const FRAGMENT = graphql(/* GraphQL */ `
fragment UserEmail_email on UserEmail {
@@ -25,15 +34,9 @@ export const FRAGMENT = graphql(/* GraphQL */ `
}
`);
export const CONFIG_FRAGMENT = graphql(/* GraphQL */ `
fragment UserEmail_siteConfig on SiteConfig {
emailChangeAllowed
}
`);
const REMOVE_EMAIL_MUTATION = graphql(/* GraphQL */ `
mutation RemoveEmail($id: ID!) {
removeEmail(input: { userEmailId: $id }) {
mutation RemoveEmail($id: ID!, $password: String) {
removeEmail(input: { userEmailId: $id, password: $password }) {
status
user {
@@ -64,92 +67,135 @@ const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({
</Translation>
);
const DeleteButtonWithConfirmation: React.FC<
ComponentProps<typeof DeleteButton> & { email: string }
> = ({ email, onClick, ...rest }) => {
const { t } = useTranslation();
const onConfirm = (): void => {
onClick?.();
};
// NOOP function, otherwise we dont render a cancel button
const onDeny = (): void => {};
return (
<Dialog trigger={<DeleteButton {...rest} />}>
<Title>
{t("frontend.user_email.delete_button_confirmation_modal.body")}
</Title>
<Description className={styles.emailModalBox}>
<IconEmail />
<div>{email}</div>
</Description>
<div className="flex flex-col gap-4">
<Close asChild>
<Button
kind="primary"
destructive
onClick={onConfirm}
Icon={IconDelete}
>
{t("frontend.user_email.delete_button_confirmation_modal.action")}
</Button>
</Close>
<Close asChild>
<Button kind="tertiary" onClick={onDeny}>
{t("action.cancel")}
</Button>
</Close>
</div>
</Dialog>
);
};
const UserEmail: React.FC<{
email: FragmentType<typeof FRAGMENT>;
canRemove?: boolean;
shouldPromptPassword?: boolean;
onRemove?: () => void;
}> = ({ email, canRemove, onRemove }) => {
}> = ({ email, canRemove, shouldPromptPassword, onRemove }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const data = useFragment(FRAGMENT, email);
const queryClient = useQueryClient();
const [promptPassword, passwordConfirmationRef] = usePasswordConfirmation();
const removeEmail = useMutation({
mutationFn: (id: string) =>
graphqlRequest({ query: REMOVE_EMAIL_MUTATION, variables: { id } }),
onSuccess: (_data) => {
onRemove?.();
mutationFn: ({ id, password }: { id: string; password?: string }) =>
graphqlRequest({
query: REMOVE_EMAIL_MUTATION,
variables: { id, password },
}),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] });
queryClient.invalidateQueries({ queryKey: ["userEmails"] });
// Don't close the modal unless the mutation was successful removed (or not found)
if (
data.removeEmail.status !== "NOT_FOUND" &&
data.removeEmail.status !== "REMOVED"
) {
return;
}
onRemove?.();
setOpen(false);
},
});
const onRemoveClick = (): void => {
removeEmail.mutate(data.id);
};
const onRemoveClick = useCallback(
async (_e: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
let password = undefined;
if (shouldPromptPassword) {
password = await promptPassword();
}
removeEmail.mutate({ id: data.id, password });
},
[data.id, promptPassword, shouldPromptPassword, removeEmail.mutate],
);
const onOpenChange = useCallback(
(open: boolean) => {
// Don't change the modal state if the mutation is pending
if (removeEmail.isPending) return;
removeEmail.reset();
setOpen(open);
},
[removeEmail.isPending, removeEmail.reset],
);
const status = removeEmail.data?.removeEmail.status ?? null;
return (
<Form.Root>
<Form.Field name="email">
<Form.Label>{t("frontend.user_email.email")}</Form.Label>
<>
<PasswordConfirmationModal
title={t(
"frontend.user_email.delete_button_confirmation_modal.password_confirmation",
)}
destructive
ref={passwordConfirmationRef}
/>
<Form.Root>
<Form.Field name="email">
<Form.Label>{t("frontend.user_email.email")}</Form.Label>
<div className="flex items-center gap-2">
<Form.TextControl
type="email"
readOnly
value={data.email}
className={styles.userEmailField}
/>
{canRemove && (
<DeleteButtonWithConfirmation
email={data.email}
disabled={removeEmail.isPending}
onClick={onRemoveClick}
<div className="flex items-center gap-2">
<Form.TextControl
type="email"
readOnly
value={data.email}
className={styles.userEmailField}
/>
)}
</div>
</Form.Field>
</Form.Root>
{canRemove && (
<Dialog
trigger={<DeleteButton />}
open={open}
onOpenChange={onOpenChange}
>
<Title>
{t(
"frontend.user_email.delete_button_confirmation_modal.body",
)}
</Title>
<Description className={styles.emailModalBox}>
<IconEmail />
<div>{data.email}</div>
</Description>
{status === "INCORRECT_PASSWORD" && (
<ErrorMessage>
{t(
"frontend.user_email.delete_button_confirmation_modal.incorrect_password",
)}
</ErrorMessage>
)}
<div className="flex flex-col gap-4">
<Button
kind="primary"
type="button"
destructive
onClick={onRemoveClick}
disabled={removeEmail.isPending}
Icon={removeEmail.isPending ? undefined : IconDelete}
>
{!!removeEmail.isPending && <LoadingSpinner inline />}
{t(
"frontend.user_email.delete_button_confirmation_modal.action",
)}
</Button>
<Close asChild>
<Button disabled={removeEmail.isPending} kind="tertiary">
{t("action.cancel")}
</Button>
</Close>
</div>
</Dialog>
)}
</div>
</Form.Field>
</Form.Root>
</>
);
};
@@ -10,13 +10,33 @@ import {
ErrorMessage,
HelpMessage,
} from "@vector-im/compound-web";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { graphql } from "../../gql";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { graphqlRequest } from "../../graphql";
import PasswordConfirmationModal, {
usePasswordConfirmation,
} from "../PasswordConfirmation";
export const USER_FRAGMENT = graphql(/* GraphQL */ `
fragment AddEmailForm_user on User {
hasPassword
}
`);
export const CONFIG_FRAGMENT = graphql(/* GraphQL */ `
fragment AddEmailForm_siteConfig on SiteConfig {
passwordLoginEnabled
}
`);
const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ `
mutation AddEmail($email: String!, $language: String!) {
startEmailAuthentication(input: { email: $email, language: $language }) {
mutation AddEmail($email: String!, $password: String, $language: String!) {
startEmailAuthentication(input: {
email: $email,
password: $password,
language: $language
}) {
status
violations
authentication {
@@ -28,14 +48,26 @@ const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ `
const AddEmailForm: React.FC<{
onAdd: (id: string) => Promise<void>;
}> = ({ onAdd }) => {
user: FragmentType<typeof USER_FRAGMENT>;
siteConfig: FragmentType<typeof CONFIG_FRAGMENT>;
}> = ({ user, siteConfig, onAdd }) => {
const { hasPassword } = useFragment(USER_FRAGMENT, user);
const { passwordLoginEnabled } = useFragment(CONFIG_FRAGMENT, siteConfig);
const shouldPromptPassword = hasPassword && passwordLoginEnabled;
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [promptPassword, passwordConfirmationRef] = usePasswordConfirmation();
const addEmail = useMutation({
mutationFn: ({ email, language }: { email: string; language: string }) =>
mutationFn: ({
email,
password,
language,
}: { email: string; password?: string; language: string }) =>
graphqlRequest({
query: ADD_EMAIL_MUTATION,
variables: { email, language },
variables: { email, password, language },
}),
onSuccess: async (data) => {
queryClient.invalidateQueries({ queryKey: ["userEmails"] });
@@ -54,62 +86,96 @@ const AddEmailForm: React.FC<{
},
});
const handleSubmit = async (
e: React.FormEvent<HTMLFormElement>,
): Promise<void> => {
e.preventDefault();
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const email = formData.get("input") as string;
await addEmail.mutateAsync({ email, language: i18n.languages[0] });
};
const formData = new FormData(e.currentTarget);
const email = formData.get("input") as string;
let password = undefined;
if (shouldPromptPassword) {
password = await promptPassword();
}
const data = await addEmail.mutateAsync({
email,
password,
language: i18n.languages[0],
});
if (data.startEmailAuthentication.status !== "STARTED") {
// This is so that the 'Edit in place' component doesn't show a 'Saved' message
throw new Error();
}
},
[
addEmail.mutateAsync,
shouldPromptPassword,
promptPassword,
i18n.languages,
],
);
const status = addEmail.data?.startEmailAuthentication.status ?? null;
const violations = addEmail.data?.startEmailAuthentication.violations ?? [];
return (
<EditInPlace
onSave={handleSubmit}
required
type="email"
serverInvalid={!!status && status !== "STARTED"}
label={t("frontend.add_email_form.email_field_label")}
helpLabel={t("frontend.add_email_form.email_field_help")}
saveButtonLabel={t("action.save")}
savingLabel={t("common.saving")}
savedLabel={t("common.saved")}
cancelButtonLabel={t("action.cancel")}
>
<ErrorMessage
match="typeMismatch"
forceMatch={status === "INVALID_EMAIL_ADDRESS"}
<>
<PasswordConfirmationModal
title={t("frontend.add_email_form.password_confirmation")}
ref={passwordConfirmationRef}
/>
<EditInPlace
onSave={handleSubmit}
required
type="email"
serverInvalid={!!status && status !== "STARTED"}
label={t("frontend.add_email_form.email_field_label")}
helpLabel={t("frontend.add_email_form.email_field_help")}
saveButtonLabel={t("action.save")}
savingLabel={t("common.saving")}
savedLabel={t("common.saved")}
cancelButtonLabel={t("action.cancel")}
>
{t("frontend.add_email_form.email_invalid_error")}
</ErrorMessage>
{status === "IN_USE" && (
<ErrorMessage>
{t("frontend.add_email_form.email_in_use_error")}
<ErrorMessage
match="typeMismatch"
forceMatch={status === "INVALID_EMAIL_ADDRESS"}
>
{t("frontend.add_email_form.email_invalid_error")}
</ErrorMessage>
)}
{status === "RATE_LIMITED" && (
<ErrorMessage>{t("frontend.errors.rate_limit_exceeded")}</ErrorMessage>
)}
{status === "DENIED" && (
<>
{status === "IN_USE" && (
<ErrorMessage>
{t("frontend.add_email_form.email_denied_error")}
{t("frontend.add_email_form.email_in_use_error")}
</ErrorMessage>
)}
{violations.map((violation) => (
// XXX: those messages are bad, but it's better to show them than show a generic message
<HelpMessage key={violation}>{violation}</HelpMessage>
))}
</>
)}
</EditInPlace>
{status === "RATE_LIMITED" && (
<ErrorMessage>
{t("frontend.errors.rate_limit_exceeded")}
</ErrorMessage>
)}
{status === "DENIED" && (
<>
<ErrorMessage>
{t("frontend.add_email_form.email_denied_error")}
</ErrorMessage>
{violations.map((violation) => (
// XXX: those messages are bad, but it's better to show them than show a generic message
<HelpMessage key={violation}>{violation}</HelpMessage>
))}
</>
)}
{status === "INCORRECT_PASSWORD" && (
<ErrorMessage>
{t("frontend.add_email_form.incorrect_password_error")}
</ErrorMessage>
)}
</EditInPlace>
</>
);
};
@@ -60,16 +60,30 @@ export const query = (pagination: AnyPagination = { first: 6 }) =>
}),
});
export const USER_FRAGMENT = graphql(/* GraphQL */ `
fragment UserEmailList_user on User {
hasPassword
}
`);
export const CONFIG_FRAGMENT = graphql(/* GraphQL */ `
fragment UserEmailList_siteConfig on SiteConfig {
emailChangeAllowed
passwordLoginEnabled
}
`);
const UserEmailList: React.FC<{
siteConfig: FragmentType<typeof CONFIG_FRAGMENT>;
}> = ({ siteConfig }) => {
const { emailChangeAllowed } = useFragment(CONFIG_FRAGMENT, siteConfig);
user: FragmentType<typeof USER_FRAGMENT>;
}> = ({ siteConfig, user }) => {
const { emailChangeAllowed, passwordLoginEnabled } = useFragment(
CONFIG_FRAGMENT,
siteConfig,
);
const { hasPassword } = useFragment(USER_FRAGMENT, user);
const shouldPromptPassword = hasPassword && passwordLoginEnabled;
const [pending, startTransition] = useTransition();
const [pagination, setPagination] = usePagination();
@@ -102,6 +116,7 @@ const UserEmailList: React.FC<{
email={edge.node}
key={edge.cursor}
canRemove={canRemove}
shouldPromptPassword={shouldPromptPassword}
onRemove={onRemove}
/>
))}
+30 -18
View File
@@ -33,16 +33,18 @@ type Documents = {
"\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndCompatSessionButton_session\n\n userAgent {\n name\n os\n model\n }\n\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.CompatSession_DetailFragmentDoc,
"\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n }\n\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": typeof types.OAuth2Session_DetailFragmentDoc,
"\n fragment UserEmail_email on UserEmail {\n id\n email\n }\n": typeof types.UserEmail_EmailFragmentDoc,
"\n fragment UserEmail_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n": typeof types.UserEmail_SiteConfigFragmentDoc,
"\n mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n": typeof types.RemoveEmailDocument,
"\n mutation RemoveEmail($id: ID!, $password: String) {\n removeEmail(input: { userEmailId: $id, password: $password }) {\n status\n\n user {\n id\n }\n }\n }\n": typeof types.RemoveEmailDocument,
"\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": typeof types.UserGreeting_UserFragmentDoc,
"\n fragment UserGreeting_siteConfig on SiteConfig {\n displayNameChangeAllowed\n }\n": typeof types.UserGreeting_SiteConfigFragmentDoc,
"\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n": typeof types.SetDisplayNameDocument,
"\n mutation AddEmail($email: String!, $language: String!) {\n startEmailAuthentication(input: { email: $email, language: $language }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": typeof types.AddEmailDocument,
"\n fragment AddEmailForm_user on User {\n hasPassword\n }\n": typeof types.AddEmailForm_UserFragmentDoc,
"\n fragment AddEmailForm_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": typeof types.AddEmailForm_SiteConfigFragmentDoc,
"\n mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(input: {\n email: $email,\n password: $password,\n language: $language\n }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": typeof types.AddEmailDocument,
"\n query UserEmailList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": typeof types.UserEmailListDocument,
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc,
"\n fragment UserEmailList_user on User {\n hasPassword\n }\n": typeof types.UserEmailList_UserFragmentDoc,
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc,
"\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": typeof types.BrowserSessionsOverview_UserFragmentDoc,
"\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": typeof types.UserProfileDocument,
"\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": typeof types.UserProfileDocument,
"\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": typeof types.BrowserSessionListDocument,
"\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": typeof types.SessionsOverviewDocument,
"\n query AppSessionsList(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n": typeof types.AppSessionsListDocument,
@@ -82,16 +84,18 @@ const documents: Documents = {
"\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndCompatSessionButton_session\n\n userAgent {\n name\n os\n model\n }\n\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_DetailFragmentDoc,
"\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n }\n\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": types.OAuth2Session_DetailFragmentDoc,
"\n fragment UserEmail_email on UserEmail {\n id\n email\n }\n": types.UserEmail_EmailFragmentDoc,
"\n fragment UserEmail_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n": types.UserEmail_SiteConfigFragmentDoc,
"\n mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n": types.RemoveEmailDocument,
"\n mutation RemoveEmail($id: ID!, $password: String) {\n removeEmail(input: { userEmailId: $id, password: $password }) {\n status\n\n user {\n id\n }\n }\n }\n": types.RemoveEmailDocument,
"\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": types.UserGreeting_UserFragmentDoc,
"\n fragment UserGreeting_siteConfig on SiteConfig {\n displayNameChangeAllowed\n }\n": types.UserGreeting_SiteConfigFragmentDoc,
"\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n": types.SetDisplayNameDocument,
"\n mutation AddEmail($email: String!, $language: String!) {\n startEmailAuthentication(input: { email: $email, language: $language }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": types.AddEmailDocument,
"\n fragment AddEmailForm_user on User {\n hasPassword\n }\n": types.AddEmailForm_UserFragmentDoc,
"\n fragment AddEmailForm_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": types.AddEmailForm_SiteConfigFragmentDoc,
"\n mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(input: {\n email: $email,\n password: $password,\n language: $language\n }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": types.AddEmailDocument,
"\n query UserEmailList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": types.UserEmailListDocument,
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n": types.UserEmailList_SiteConfigFragmentDoc,
"\n fragment UserEmailList_user on User {\n hasPassword\n }\n": types.UserEmailList_UserFragmentDoc,
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": types.UserEmailList_SiteConfigFragmentDoc,
"\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.BrowserSessionsOverview_UserFragmentDoc,
"\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": types.UserProfileDocument,
"\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": types.UserProfileDocument,
"\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": types.BrowserSessionListDocument,
"\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": types.SessionsOverviewDocument,
"\n query AppSessionsList(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n": types.AppSessionsListDocument,
@@ -188,11 +192,7 @@ export function graphql(source: "\n fragment UserEmail_email on UserEmail {\n
/**
* 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 UserEmail_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n"): typeof import('./graphql').UserEmail_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 mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n"): typeof import('./graphql').RemoveEmailDocument;
export function graphql(source: "\n mutation RemoveEmail($id: ID!, $password: String) {\n removeEmail(input: { userEmailId: $id, password: $password }) {\n status\n\n user {\n id\n }\n }\n }\n"): typeof import('./graphql').RemoveEmailDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -208,7 +208,15 @@ export function graphql(source: "\n mutation SetDisplayName($userId: ID!, $disp
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation AddEmail($email: String!, $language: String!) {\n startEmailAuthentication(input: { email: $email, language: $language }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n"): typeof import('./graphql').AddEmailDocument;
export function graphql(source: "\n fragment AddEmailForm_user on User {\n hasPassword\n }\n"): typeof import('./graphql').AddEmailForm_UserFragmentDoc;
/**
* 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 AddEmailForm_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n"): typeof import('./graphql').AddEmailForm_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 mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(input: {\n email: $email,\n password: $password,\n language: $language\n }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n"): typeof import('./graphql').AddEmailDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -216,7 +224,11 @@ export function graphql(source: "\n query UserEmailList(\n $first: Int\n
/**
* 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 UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n"): typeof import('./graphql').UserEmailList_SiteConfigFragmentDoc;
export function graphql(source: "\n fragment UserEmailList_user on User {\n hasPassword\n }\n"): typeof import('./graphql').UserEmailList_UserFragmentDoc;
/**
* 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 UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n"): typeof import('./graphql').UserEmailList_SiteConfigFragmentDoc;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -224,7 +236,7 @@ export function graphql(source: "\n fragment BrowserSessionsOverview_user on Us
/**
* 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 UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n"): typeof import('./graphql').UserProfileDocument;
export function graphql(source: "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n"): typeof import('./graphql').UserProfileDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
+64 -19
View File
@@ -935,6 +935,11 @@ export type QueryUsersArgs = {
/** The input for the `removeEmail` mutation */
export type RemoveEmailInput = {
/**
* The user's current password. This is required if the user is not an
* admin and it has a password on its account.
*/
password?: InputMaybe<Scalars['String']['input']>;
/** The ID of the email address to remove */
userEmailId: Scalars['ID']['input'];
};
@@ -952,6 +957,8 @@ export type RemoveEmailPayload = {
/** The status of the `removeEmail` mutation */
export type RemoveEmailStatus =
/** The password provided is incorrect */
| 'INCORRECT_PASSWORD'
/** The email address was not found */
| 'NOT_FOUND'
/** The email address was removed */
@@ -1190,6 +1197,11 @@ export type StartEmailAuthenticationInput = {
email: Scalars['String']['input'];
/** The language to use for the email */
language?: Scalars['String']['input'];
/**
* The user's current password. This is required if the user has a password
* on its account.
*/
password?: InputMaybe<Scalars['String']['input']>;
};
/** The payload of the `startEmailAuthentication` mutation */
@@ -1207,6 +1219,8 @@ export type StartEmailAuthenticationPayload = {
export type StartEmailAuthenticationStatus =
/** The email address isn't allowed by the policy */
| 'DENIED'
/** The password provided is incorrect */
| 'INCORRECT_PASSWORD'
/** The email address is invalid */
| 'INVALID_EMAIL_ADDRESS'
/** The email address is already in use on this account */
@@ -1640,10 +1654,9 @@ export type OAuth2Session_DetailFragment = (
export type UserEmail_EmailFragment = { __typename?: 'UserEmail', id: string, email: string } & { ' $fragmentName'?: 'UserEmail_EmailFragment' };
export type UserEmail_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean } & { ' $fragmentName'?: 'UserEmail_SiteConfigFragment' };
export type RemoveEmailMutationVariables = Exact<{
id: Scalars['ID']['input'];
password?: InputMaybe<Scalars['String']['input']>;
}>;
@@ -1661,8 +1674,13 @@ export type SetDisplayNameMutationVariables = Exact<{
export type SetDisplayNameMutation = { __typename?: 'Mutation', setDisplayName: { __typename?: 'SetDisplayNamePayload', status: SetDisplayNameStatus } };
export type AddEmailForm_UserFragment = { __typename?: 'User', hasPassword: boolean } & { ' $fragmentName'?: 'AddEmailForm_UserFragment' };
export type AddEmailForm_SiteConfigFragment = { __typename?: 'SiteConfig', passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'AddEmailForm_SiteConfigFragment' };
export type AddEmailMutationVariables = Exact<{
email: Scalars['String']['input'];
password?: InputMaybe<Scalars['String']['input']>;
language: Scalars['String']['input'];
}>;
@@ -1682,16 +1700,21 @@ export type UserEmailListQuery = { __typename?: 'Query', viewer: { __typename: '
& { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } }
) }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } };
export type UserEmailList_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean } & { ' $fragmentName'?: 'UserEmailList_SiteConfigFragment' };
export type UserEmailList_UserFragment = { __typename?: 'User', hasPassword: boolean } & { ' $fragmentName'?: 'UserEmailList_UserFragment' };
export type UserEmailList_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'UserEmailList_SiteConfigFragment' };
export type BrowserSessionsOverview_UserFragment = { __typename?: 'User', id: string, browserSessions: { __typename?: 'BrowserSessionConnection', totalCount: number } } & { ' $fragmentName'?: 'BrowserSessionsOverview_UserFragment' };
export type UserProfileQueryVariables = Exact<{ [key: string]: never; }>;
export type UserProfileQuery = { __typename?: 'Query', viewerSession: { __typename: 'Anonymous' } | { __typename: 'BrowserSession', id: string, user: { __typename?: 'User', hasPassword: boolean, emails: { __typename?: 'UserEmailConnection', totalCount: number } } } | { __typename: 'Oauth2Session' }, siteConfig: (
export type UserProfileQuery = { __typename?: 'Query', viewerSession: { __typename: 'Anonymous' } | { __typename: 'BrowserSession', id: string, user: (
{ __typename?: 'User', hasPassword: boolean, emails: { __typename?: 'UserEmailConnection', totalCount: number } }
& { ' $fragmentRefs'?: { 'AddEmailForm_UserFragment': AddEmailForm_UserFragment;'UserEmailList_UserFragment': UserEmailList_UserFragment } }
) } | { __typename: 'Oauth2Session' }, siteConfig: (
{ __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean }
& { ' $fragmentRefs'?: { 'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'UserEmail_SiteConfigFragment': UserEmail_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment } }
& { ' $fragmentRefs'?: { 'AddEmailForm_SiteConfigFragment': AddEmailForm_SiteConfigFragment;'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment } }
) };
export type BrowserSessionListQueryVariables = Exact<{
@@ -2151,11 +2174,6 @@ export const UserEmail_EmailFragmentDoc = new TypedDocumentString(`
email
}
`, {"fragmentName":"UserEmail_email"}) as unknown as TypedDocumentString<UserEmail_EmailFragment, unknown>;
export const UserEmail_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment UserEmail_siteConfig on SiteConfig {
emailChangeAllowed
}
`, {"fragmentName":"UserEmail_siteConfig"}) as unknown as TypedDocumentString<UserEmail_SiteConfigFragment, unknown>;
export const UserGreeting_UserFragmentDoc = new TypedDocumentString(`
fragment UserGreeting_user on User {
id
@@ -2170,9 +2188,25 @@ export const UserGreeting_SiteConfigFragmentDoc = new TypedDocumentString(`
displayNameChangeAllowed
}
`, {"fragmentName":"UserGreeting_siteConfig"}) as unknown as TypedDocumentString<UserGreeting_SiteConfigFragment, unknown>;
export const AddEmailForm_UserFragmentDoc = new TypedDocumentString(`
fragment AddEmailForm_user on User {
hasPassword
}
`, {"fragmentName":"AddEmailForm_user"}) as unknown as TypedDocumentString<AddEmailForm_UserFragment, unknown>;
export const AddEmailForm_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment AddEmailForm_siteConfig on SiteConfig {
passwordLoginEnabled
}
`, {"fragmentName":"AddEmailForm_siteConfig"}) as unknown as TypedDocumentString<AddEmailForm_SiteConfigFragment, unknown>;
export const UserEmailList_UserFragmentDoc = new TypedDocumentString(`
fragment UserEmailList_user on User {
hasPassword
}
`, {"fragmentName":"UserEmailList_user"}) as unknown as TypedDocumentString<UserEmailList_UserFragment, unknown>;
export const UserEmailList_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment UserEmailList_siteConfig on SiteConfig {
emailChangeAllowed
passwordLoginEnabled
}
`, {"fragmentName":"UserEmailList_siteConfig"}) as unknown as TypedDocumentString<UserEmailList_SiteConfigFragment, unknown>;
export const BrowserSessionsOverview_UserFragmentDoc = new TypedDocumentString(`
@@ -2247,8 +2281,8 @@ export const EndOAuth2SessionDocument = new TypedDocumentString(`
}
`) as unknown as TypedDocumentString<EndOAuth2SessionMutation, EndOAuth2SessionMutationVariables>;
export const RemoveEmailDocument = new TypedDocumentString(`
mutation RemoveEmail($id: ID!) {
removeEmail(input: {userEmailId: $id}) {
mutation RemoveEmail($id: ID!, $password: String) {
removeEmail(input: {userEmailId: $id, password: $password}) {
status
user {
id
@@ -2264,8 +2298,10 @@ export const SetDisplayNameDocument = new TypedDocumentString(`
}
`) as unknown as TypedDocumentString<SetDisplayNameMutation, SetDisplayNameMutationVariables>;
export const AddEmailDocument = new TypedDocumentString(`
mutation AddEmail($email: String!, $language: String!) {
startEmailAuthentication(input: {email: $email, language: $language}) {
mutation AddEmail($email: String!, $password: String, $language: String!) {
startEmailAuthentication(
input: {email: $email, password: $password, language: $language}
) {
status
violations
authentication {
@@ -2308,6 +2344,8 @@ export const UserProfileDocument = new TypedDocumentString(`
... on BrowserSession {
id
user {
...AddEmailForm_user
...UserEmailList_user
hasPassword
emails(first: 0) {
totalCount
@@ -2318,19 +2356,26 @@ export const UserProfileDocument = new TypedDocumentString(`
siteConfig {
emailChangeAllowed
passwordLoginEnabled
...AddEmailForm_siteConfig
...UserEmailList_siteConfig
...UserEmail_siteConfig
...PasswordChange_siteConfig
}
}
fragment PasswordChange_siteConfig on SiteConfig {
passwordChangeAllowed
}
fragment UserEmail_siteConfig on SiteConfig {
emailChangeAllowed
fragment AddEmailForm_user on User {
hasPassword
}
fragment AddEmailForm_siteConfig on SiteConfig {
passwordLoginEnabled
}
fragment UserEmailList_user on User {
hasPassword
}
fragment UserEmailList_siteConfig on SiteConfig {
emailChangeAllowed
passwordLoginEnabled
}`) as unknown as TypedDocumentString<UserProfileQuery, UserProfileQueryVariables>;
export const BrowserSessionListDocument = new TypedDocumentString(`
query BrowserSessionList($first: Int, $after: String, $last: Int, $before: String, $lastActive: DateFilter) {
@@ -2865,7 +2910,7 @@ export const mockEndOAuth2SessionMutation = (resolver: GraphQLResponseResolver<E
* @example
* mockRemoveEmailMutation(
* ({ query, variables }) => {
* const { id } = variables;
* const { id, password } = variables;
* return HttpResponse.json({
* data: { removeEmail }
* })
@@ -2909,7 +2954,7 @@ export const mockSetDisplayNameMutation = (resolver: GraphQLResponseResolver<Set
* @example
* mockAddEmailMutation(
* ({ query, variables }) => {
* const { email, language } = variables;
* const { email, password, language } = variables;
* return HttpResponse.json({
* data: { startEmailAuthentication }
* })
+11 -2
View File
@@ -85,9 +85,18 @@ function Index(): React.ReactElement {
defaultOpen
title={t("frontend.account.contact_info")}
>
<UserEmailList siteConfig={siteConfig} />
<UserEmailList
user={viewerSession.user}
siteConfig={siteConfig}
/>
{siteConfig.emailChangeAllowed && <AddEmailForm onAdd={onAdd} />}
{siteConfig.emailChangeAllowed && (
<AddEmailForm
user={viewerSession.user}
siteConfig={siteConfig}
onAdd={onAdd}
/>
)}
</Collapsible.Section>
<Separator kind="section" />
+3 -1
View File
@@ -18,6 +18,8 @@ const QUERY = graphql(/* GraphQL */ `
... on BrowserSession {
id
user {
...AddEmailForm_user
...UserEmailList_user
hasPassword
emails(first: 0) {
totalCount
@@ -29,8 +31,8 @@ const QUERY = graphql(/* GraphQL */ `
siteConfig {
emailChangeAllowed
passwordLoginEnabled
...AddEmailForm_siteConfig
...UserEmailList_siteConfig
...UserEmail_siteConfig
...PasswordChange_siteConfig
}
}
+30 -10
View File
@@ -8,11 +8,15 @@ import { expect, userEvent, waitFor, within } from "@storybook/test";
import i18n from "i18next";
import { type GraphQLHandler, HttpResponse } from "msw";
import { CONFIG_FRAGMENT as PASSWORD_CHANGE_CONFIG_FRAGMENT } from "../../src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview";
import { FRAGMENT as USER_EMAIL_FRAGMENT } from "../../src/components/UserEmail/UserEmail";
import {
CONFIG_FRAGMENT as USER_EMAIL_CONFIG_FRAGMENT,
FRAGMENT as USER_EMAIL_FRAGMENT,
} from "../../src/components/UserEmail/UserEmail";
import { CONFIG_FRAGMENT as USER_EMAIL_LIST_CONFIG_FRAGMENT } from "../../src/components/UserProfile/UserEmailList";
CONFIG_FRAGMENT as ADD_USER_EMAIL_CONFIG_FRAGMENT,
USER_FRAGMENT as ADD_USER_EMAIL_USER_FRAGMENT,
} from "../../src/components/UserProfile/AddEmailForm";
import {
CONFIG_FRAGMENT as USER_EMAIL_LIST_CONFIG_FRAGMENT,
USER_FRAGMENT as USER_EMAIL_LIST_USER_FRAGMENT,
} from "../../src/components/UserProfile/UserEmailList";
import { makeFragmentData } from "../../src/gql";
import {
mockUserEmailListQuery,
@@ -48,12 +52,26 @@ const userProfileHandler = ({
viewerSession: {
__typename: "BrowserSession",
id: "session-id",
user: {
hasPassword,
emails: {
totalCount: emailTotalCount,
user: Object.assign(
{
hasPassword,
emails: {
totalCount: emailTotalCount,
},
},
},
makeFragmentData(
{
hasPassword,
},
ADD_USER_EMAIL_USER_FRAGMENT,
),
makeFragmentData(
{
hasPassword,
},
USER_EMAIL_LIST_USER_FRAGMENT,
),
),
},
siteConfig: Object.assign(
@@ -64,12 +82,14 @@ const userProfileHandler = ({
makeFragmentData(
{
emailChangeAllowed,
passwordLoginEnabled,
},
USER_EMAIL_CONFIG_FRAGMENT,
ADD_USER_EMAIL_CONFIG_FRAGMENT,
),
makeFragmentData(
{
emailChangeAllowed,
passwordLoginEnabled,
},
USER_EMAIL_LIST_CONFIG_FRAGMENT,
),
+31 -11
View File
@@ -6,15 +6,19 @@
import { HttpResponse } from "msw";
import { CONFIG_FRAGMENT as PASSWORD_CHANGE_CONFIG_FRAGMENT } from "../../src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview";
import { FRAGMENT as FOOTER_FRAGMENT } from "../../src/components/Footer/Footer";
import {
CONFIG_FRAGMENT as USER_EMAIL_CONFIG_FRAGMENT,
FRAGMENT as USER_EMAIL_FRAGMENT,
} from "../../src/components/UserEmail/UserEmail";
import { FRAGMENT as USER_EMAIL_FRAGMENT } from "../../src/components/UserEmail/UserEmail";
import {
CONFIG_FRAGMENT as USER_GREETING_CONFIG_FRAGMENT,
FRAGMENT as USER_GREETING_FRAGMENT,
} from "../../src/components/UserGreeting/UserGreeting";
import { CONFIG_FRAGMENT as USER_EMAIL_LIST_CONFIG_FRAGMENT } from "../../src/components/UserProfile/UserEmailList";
import {
CONFIG_FRAGMENT as ADD_USER_EMAIL_CONFIG_FRAGMENT,
USER_FRAGMENT as ADD_USER_EMAIL_USER_FRAGMENT,
} from "../../src/components/UserProfile/AddEmailForm";
import {
CONFIG_FRAGMENT as USER_EMAIL_LIST_CONFIG_FRAGMENT,
USER_FRAGMENT as USER_EMAIL_LIST_USER_FRAGMENT,
} from "../../src/components/UserProfile/UserEmailList";
import { makeFragmentData } from "../../src/gql";
import {
mockCurrentUserGreetingQuery,
@@ -90,12 +94,26 @@ export const handlers = [
viewerSession: {
__typename: "BrowserSession",
id: "browser-session-id",
user: {
hasPassword: true,
emails: {
totalCount: 1,
user: Object.assign(
{
hasPassword: true,
emails: {
totalCount: 1,
},
},
},
makeFragmentData(
{
hasPassword: true,
},
ADD_USER_EMAIL_USER_FRAGMENT,
),
makeFragmentData(
{
hasPassword: true,
},
USER_EMAIL_LIST_USER_FRAGMENT,
),
),
},
siteConfig: Object.assign(
@@ -106,12 +124,14 @@ export const handlers = [
makeFragmentData(
{
emailChangeAllowed: true,
passwordLoginEnabled: true,
},
USER_EMAIL_CONFIG_FRAGMENT,
ADD_USER_EMAIL_CONFIG_FRAGMENT,
),
makeFragmentData(
{
emailChangeAllowed: true,
passwordLoginEnabled: true,
},
USER_EMAIL_LIST_CONFIG_FRAGMENT,
),
@@ -2,18 +2,18 @@
exports[`Account home page > display name edit box > displays an error if the display name is invalid 1`] = `
<div
aria-describedby="radix-:r72:"
aria-labelledby="radix-:r71:"
aria-describedby="radix-:r7q:"
aria-labelledby="radix-:r7p:"
class="_body_9cf7b0"
data-state="open"
id="radix-:r70:"
id="radix-:r7o:"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
>
<h2
class="_title_9cf7b0"
id="radix-:r71:"
id="radix-:r7p:"
>
Edit profile
</h2>
@@ -40,29 +40,29 @@ exports[`Account home page > display name edit box > displays an error if the di
<label
class="_label_19upo_59"
data-invalid="true"
for="radix-:r8c:"
for="radix-:r9a:"
>
Display name
</label>
<div
class="_container_1s836_8"
id=":r8d:"
id=":r9b:"
>
<input
aria-describedby="radix-:r8j:"
aria-describedby="radix-:r9h:"
aria-invalid="true"
autocomplete="name"
class="_control_sqdq4_10 _control_1s836_13"
data-invalid="true"
id="radix-:r8c:"
id="radix-:r9a:"
name="displayname"
title=""
type="text"
value="Alice"
/>
<button
aria-controls=":r8d:"
aria-labelledby=":r8e:"
aria-controls=":r9b:"
aria-labelledby=":r9c:"
class="_action_1s836_24"
type="button"
>
@@ -82,7 +82,7 @@ exports[`Account home page > display name edit box > displays an error if the di
</div>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-:r8j:"
id="radix-:r9h:"
>
This is what others will see wherever youre signed in.
</span>
@@ -92,13 +92,13 @@ exports[`Account home page > display name edit box > displays an error if the di
>
<label
class="_label_19upo_59"
for="radix-:r8k:"
for="radix-:r9i:"
>
Username
</label>
<input
class="_control_sqdq4_10"
id="radix-:r8k:"
id="radix-:r9i:"
name="mxid"
readonly=""
title=""
@@ -129,7 +129,7 @@ exports[`Account home page > display name edit box > displays an error if the di
Cancel
</button>
<button
aria-labelledby=":r8l:"
aria-labelledby=":r9j:"
class="_close_9cf7b0"
type="button"
>
@@ -150,18 +150,18 @@ exports[`Account home page > display name edit box > displays an error if the di
exports[`Account home page > display name edit box > lets edit the display name 1`] = `
<div
aria-describedby="radix-:r1e:"
aria-labelledby="radix-:r1d:"
aria-describedby="radix-:r1k:"
aria-labelledby="radix-:r1j:"
class="_body_9cf7b0"
data-state="open"
id="radix-:r1c:"
id="radix-:r1i:"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
>
<h2
class="_title_9cf7b0"
id="radix-:r1d:"
id="radix-:r1j:"
>
Edit profile
</h2>
@@ -186,27 +186,27 @@ exports[`Account home page > display name edit box > lets edit the display name
>
<label
class="_label_19upo_59"
for="radix-:r2o:"
for="radix-:r34:"
>
Display name
</label>
<div
class="_container_1s836_8"
id=":r2p:"
id=":r35:"
>
<input
aria-describedby="radix-:r2v:"
aria-describedby="radix-:r3b:"
autocomplete="name"
class="_control_sqdq4_10 _control_1s836_13"
id="radix-:r2o:"
id="radix-:r34:"
name="displayname"
title=""
type="text"
value="Alice"
/>
<button
aria-controls=":r2p:"
aria-labelledby=":r2q:"
aria-controls=":r35:"
aria-labelledby=":r36:"
class="_action_1s836_24"
type="button"
>
@@ -226,7 +226,7 @@ exports[`Account home page > display name edit box > lets edit the display name
</div>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-:r2v:"
id="radix-:r3b:"
>
This is what others will see wherever youre signed in.
</span>
@@ -236,13 +236,13 @@ exports[`Account home page > display name edit box > lets edit the display name
>
<label
class="_label_19upo_59"
for="radix-:r30:"
for="radix-:r3c:"
>
Username
</label>
<input
class="_control_sqdq4_10"
id="radix-:r30:"
id="radix-:r3c:"
name="mxid"
readonly=""
title=""
@@ -273,7 +273,7 @@ exports[`Account home page > display name edit box > lets edit the display name
Cancel
</button>
<button
aria-labelledby=":r31:"
aria-labelledby=":r3d:"
class="_close_9cf7b0"
type="button"
>
@@ -463,7 +463,7 @@ exports[`Account home page > renders the page 1`] = `
>
<label
class="_label_19upo_59"
for="radix-:rg:"
for="radix-:rj:"
>
Email
</label>
@@ -472,7 +472,7 @@ exports[`Account home page > renders the page 1`] = `
>
<input
class="_control_sqdq4_10 _userEmailField_e2a518"
id="radix-:rg:"
id="radix-:rj:"
name="email"
readonly=""
title=""
@@ -490,7 +490,7 @@ exports[`Account home page > renders the page 1`] = `
>
<label
class="_label_19upo_59"
for="radix-:rh:"
for="radix-:rn:"
>
Add email
</label>
@@ -498,9 +498,9 @@ exports[`Account home page > renders the page 1`] = `
class="_controls_17lij_8"
>
<input
aria-describedby="radix-:ri:"
aria-describedby="radix-:ro:"
class="_control_sqdq4_10"
id="radix-:rh:"
id="radix-:rn:"
name="input"
required=""
title=""
@@ -509,7 +509,7 @@ exports[`Account home page > renders the page 1`] = `
</div>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-:ri:"
id="radix-:ro:"
>
Add an alternative email you can use to access this account.
</span>
@@ -524,7 +524,7 @@ exports[`Account home page > renders the page 1`] = `
role="separator"
/>
<section
aria-labelledby=":rj:"
aria-labelledby=":rp:"
class="_root_f1daaa"
data-state="open"
>
@@ -536,14 +536,14 @@ exports[`Account home page > renders the page 1`] = `
>
<h4
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 _triggerTitle_f1daaa"
id=":rj:"
id=":rp:"
>
Account password
</h4>
<button
aria-controls="radix-:rl:"
aria-controls="radix-:rr:"
aria-expanded="true"
aria-labelledby=":rm:"
aria-labelledby=":rs:"
class="_icon-button_m2erp_8 _triggerIcon_f1daaa"
data-state="open"
role="button"
@@ -573,7 +573,7 @@ exports[`Account home page > renders the page 1`] = `
<article
class="_content_f1daaa"
data-state="open"
id="radix-:rl:"
id="radix-:rr:"
style="transition-duration: 0s; animation-name: none;"
>
<form
@@ -584,14 +584,14 @@ exports[`Account home page > renders the page 1`] = `
>
<label
class="_label_19upo_59"
for="radix-:rr:"
for="radix-:r11:"
>
Password
</label>
<input
aria-describedby="radix-:rs:"
aria-describedby="radix-:r12:"
class="_control_sqdq4_10"
id="radix-:rr:"
id="radix-:r11:"
name="password_preview"
readonly=""
title=""
@@ -600,7 +600,7 @@ exports[`Account home page > renders the page 1`] = `
/>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-:rs:"
id="radix-:r12:"
>
<a
class="_link_7634c3"
@@ -620,7 +620,7 @@ exports[`Account home page > renders the page 1`] = `
role="separator"
/>
<section
aria-labelledby=":rt:"
aria-labelledby=":r13:"
class="_root_f1daaa"
data-state="closed"
>
@@ -632,14 +632,14 @@ exports[`Account home page > renders the page 1`] = `
>
<h4
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 _triggerTitle_f1daaa"
id=":rt:"
id=":r13:"
>
End-to-end encryption
</h4>
<button
aria-controls="radix-:rv:"
aria-controls="radix-:r15:"
aria-expanded="false"
aria-labelledby=":r10:"
aria-labelledby=":r16:"
class="_icon-button_m2erp_8 _triggerIcon_f1daaa"
data-state="closed"
role="button"
@@ -675,7 +675,7 @@ exports[`Account home page > renders the page 1`] = `
/>
</div>
<button
aria-controls="radix-:r15:"
aria-controls="radix-:r1b:"
aria-expanded="false"
aria-haspopup="dialog"
class="_button_vczzf_8 _has-icon_vczzf_57 _destructive_vczzf_107"