diff --git a/Cargo.lock b/Cargo.lock index f1c8aba5e..ecdec9a6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 8bf011e70..809e03838 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/crates/handlers/src/graphql/mutations/user_email.rs b/crates/handlers/src/graphql/mutations/user_email.rs index cbbb10142..958b18be7 100644 --- a/crates/handlers/src/graphql/mutations/user_email.rs +++ b/crates/handlers/src/graphql/mutations/user_email.rs @@ -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, + user: &mas_data_model::User, + repo: &mut BoxRepository, +) -> Result { + // 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, } /// 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 { 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, 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, + /// 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, }, 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() diff --git a/crates/iana-codegen/Cargo.toml b/crates/iana-codegen/Cargo.toml index c685102a4..e67d927f9 100644 --- a/crates/iana-codegen/Cargo.toml +++ b/crates/iana-codegen/Cargo.toml @@ -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 diff --git a/crates/storage-pg/Cargo.toml b/crates/storage-pg/Cargo.toml index 272e0f37b..de91de6e2 100644 --- a/crates/storage-pg/Cargo.toml +++ b/crates/storage-pg/Cargo.toml @@ -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 diff --git a/crates/syn2mas/Cargo.toml b/crates/syn2mas/Cargo.toml index ba0642395..5b80b1510 100644 --- a/crates/syn2mas/Cargo.toml +++ b/crates/syn2mas/Cargo.toml @@ -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 diff --git a/crates/tasks/src/new_queue.rs b/crates/tasks/src/new_queue.rs index 6e18144e8..4d42ef67e 100644 --- a/crates/tasks/src/new_queue.rs +++ b/crates/tasks/src/new_queue.rs @@ -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>; diff --git a/deny.toml b/deny.toml index 03e979c8a..e26a3cef2 100644 --- a/deny.toml +++ b/deny.toml @@ -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] diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 1f5b4499d..dbc360a59 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f88449fd8..da8cc603f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" }, diff --git a/frontend/package.json b/frontend/package.json index dab2bcfa6..3428ad35a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 7ae680ec9..eeb9b44e4 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -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 } """ diff --git a/frontend/src/components/PasswordConfirmation.tsx b/frontend/src/components/PasswordConfirmation.tsx new file mode 100644 index 000000000..9d3accb80 --- /dev/null +++ b/frontend/src/components/PasswordConfirmation.tsx @@ -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; +}; + +type Props = { + title: string; + destructive?: boolean; + ref: React.Ref; +}; + +/** + * 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, + React.RefObject, +] => { + const ref = useRef({ + prompt: () => { + throw new Error("PasswordConfirmationModal is not mounted!"); + }, + }); + + const prompt = useCallback(() => ref.current.prompt(), []); + + return [prompt, ref] as const; +}; + +const PasswordConfirmationModal: React.FC = ({ + title, + destructive, + ref, +}) => { + const [open, setOpen] = useState(false); + const { t } = useTranslation(); + const resolversRef = useRef>(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) => { + 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 ( + + {title} + + + + {t("common.password")} + + + + + + + + + + + ); +}; + +export default PasswordConfirmationModal; diff --git a/frontend/src/components/UserEmail/UserEmail.module.css b/frontend/src/components/UserEmail/UserEmail.module.css index a59c48717..69bac1368 100644 --- a/frontend/src/components/UserEmail/UserEmail.module.css +++ b/frontend/src/components/UserEmail/UserEmail.module.css @@ -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); diff --git a/frontend/src/components/UserEmail/UserEmail.tsx b/frontend/src/components/UserEmail/UserEmail.tsx index 02771412e..725fb9e3b 100644 --- a/frontend/src/components/UserEmail/UserEmail.tsx +++ b/frontend/src/components/UserEmail/UserEmail.tsx @@ -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 }> = ({ ); -const DeleteButtonWithConfirmation: React.FC< - ComponentProps & { 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 ( - }> - - {t("frontend.user_email.delete_button_confirmation_modal.body")} - - - -
{email}
-
-
- - - - - - -
-
- ); -}; - const UserEmail: React.FC<{ email: FragmentType; 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): Promise => { + 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 ( - - - {t("frontend.user_email.email")} + <> + + + + {t("frontend.user_email.email")} -
- - {canRemove && ( - + - )} -
-
-
+ {canRemove && ( + } + open={open} + onOpenChange={onOpenChange} + > + + {t( + "frontend.user_email.delete_button_confirmation_modal.body", + )} + + + +
{data.email}
+
+ + {status === "INCORRECT_PASSWORD" && ( + + {t( + "frontend.user_email.delete_button_confirmation_modal.incorrect_password", + )} + + )} + +
+ + + + +
+
+ )} + +
+
+ ); }; diff --git a/frontend/src/components/UserProfile/AddEmailForm.tsx b/frontend/src/components/UserProfile/AddEmailForm.tsx index 6459f495e..8e8ab7962 100644 --- a/frontend/src/components/UserProfile/AddEmailForm.tsx +++ b/frontend/src/components/UserProfile/AddEmailForm.tsx @@ -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; -}> = ({ onAdd }) => { + user: FragmentType; + siteConfig: FragmentType; +}> = ({ 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, - ): Promise => { - e.preventDefault(); + const handleSubmit = useCallback( + async (e: React.FormEvent): Promise => { + 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 ( - - + + - {t("frontend.add_email_form.email_invalid_error")} - - - {status === "IN_USE" && ( - - {t("frontend.add_email_form.email_in_use_error")} + + {t("frontend.add_email_form.email_invalid_error")} - )} - {status === "RATE_LIMITED" && ( - {t("frontend.errors.rate_limit_exceeded")} - )} - - {status === "DENIED" && ( - <> + {status === "IN_USE" && ( - {t("frontend.add_email_form.email_denied_error")} + {t("frontend.add_email_form.email_in_use_error")} + )} - {violations.map((violation) => ( - // XXX: those messages are bad, but it's better to show them than show a generic message - {violation} - ))} - - )} - + {status === "RATE_LIMITED" && ( + + {t("frontend.errors.rate_limit_exceeded")} + + )} + + {status === "DENIED" && ( + <> + + {t("frontend.add_email_form.email_denied_error")} + + + {violations.map((violation) => ( + // XXX: those messages are bad, but it's better to show them than show a generic message + {violation} + ))} + + )} + + {status === "INCORRECT_PASSWORD" && ( + + {t("frontend.add_email_form.incorrect_password_error")} + + )} + + ); }; diff --git a/frontend/src/components/UserProfile/UserEmailList.tsx b/frontend/src/components/UserProfile/UserEmailList.tsx index 6db4adcf3..8c7394379 100644 --- a/frontend/src/components/UserProfile/UserEmailList.tsx +++ b/frontend/src/components/UserProfile/UserEmailList.tsx @@ -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; -}> = ({ siteConfig }) => { - const { emailChangeAllowed } = useFragment(CONFIG_FRAGMENT, siteConfig); + user: FragmentType; +}> = ({ 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} /> ))} diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 4561dfad1..5a0aa8aad 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -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. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index f77561b9c..5910ff98e 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -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; /** 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; }; /** 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; }>; @@ -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; 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; -export const UserEmail_SiteConfigFragmentDoc = new TypedDocumentString(` - fragment UserEmail_siteConfig on SiteConfig { - emailChangeAllowed -} - `, {"fragmentName":"UserEmail_siteConfig"}) as unknown as TypedDocumentString; 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; +export const AddEmailForm_UserFragmentDoc = new TypedDocumentString(` + fragment AddEmailForm_user on User { + hasPassword +} + `, {"fragmentName":"AddEmailForm_user"}) as unknown as TypedDocumentString; +export const AddEmailForm_SiteConfigFragmentDoc = new TypedDocumentString(` + fragment AddEmailForm_siteConfig on SiteConfig { + passwordLoginEnabled +} + `, {"fragmentName":"AddEmailForm_siteConfig"}) as unknown as TypedDocumentString; +export const UserEmailList_UserFragmentDoc = new TypedDocumentString(` + fragment UserEmailList_user on User { + hasPassword +} + `, {"fragmentName":"UserEmailList_user"}) as unknown as TypedDocumentString; export const UserEmailList_SiteConfigFragmentDoc = new TypedDocumentString(` fragment UserEmailList_siteConfig on SiteConfig { emailChangeAllowed + passwordLoginEnabled } `, {"fragmentName":"UserEmailList_siteConfig"}) as unknown as TypedDocumentString; export const BrowserSessionsOverview_UserFragmentDoc = new TypedDocumentString(` @@ -2247,8 +2281,8 @@ export const EndOAuth2SessionDocument = new TypedDocumentString(` } `) as unknown as TypedDocumentString; 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; 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; 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 { - * const { id } = variables; + * const { id, password } = variables; * return HttpResponse.json({ * data: { removeEmail } * }) @@ -2909,7 +2954,7 @@ export const mockSetDisplayNameMutation = (resolver: GraphQLResponseResolver { - * const { email, language } = variables; + * const { email, password, language } = variables; * return HttpResponse.json({ * data: { startEmailAuthentication } * }) diff --git a/frontend/src/routes/_account.index.lazy.tsx b/frontend/src/routes/_account.index.lazy.tsx index 22ea129a6..819fa2419 100644 --- a/frontend/src/routes/_account.index.lazy.tsx +++ b/frontend/src/routes/_account.index.lazy.tsx @@ -85,9 +85,18 @@ function Index(): React.ReactElement { defaultOpen title={t("frontend.account.contact_info")} > - + - {siteConfig.emailChangeAllowed && } + {siteConfig.emailChangeAllowed && ( + + )} diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index 026d9bc9d..0e0e19bb2 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -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 } } diff --git a/frontend/stories/routes/index.stories.tsx b/frontend/stories/routes/index.stories.tsx index 5be20d35f..a9783825c 100644 --- a/frontend/stories/routes/index.stories.tsx +++ b/frontend/stories/routes/index.stories.tsx @@ -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, ), diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts index 83719c2e3..55993aa11 100644 --- a/frontend/tests/mocks/handlers.ts +++ b/frontend/tests/mocks/handlers.ts @@ -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, ), diff --git a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap index 89a73c89d..2dbaeda34 100644 --- a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap +++ b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap @@ -2,18 +2,18 @@ exports[`Account home page > display name edit box > displays an error if the display name is invalid 1`] = ` This is what others will see wherever you’re signed in. @@ -236,13 +236,13 @@ exports[`Account home page > display name edit box > lets edit the display name > display name edit box > lets edit the display name Cancel