From a3f22ae5f6e8f0b0ced63d1b4acf0659fe033b3a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 19 Feb 2025 17:55:18 +0100 Subject: [PATCH 001/112] Allow compat session devices to have spaces --- crates/cli/src/commands/manage.rs | 2 +- crates/data-model/src/compat/device.rs | 66 +++++---------- crates/data-model/src/compat/mod.rs | 2 +- crates/data-model/src/lib.rs | 2 +- crates/handlers/src/admin/model.rs | 4 +- crates/handlers/src/graphql/query/session.rs | 13 ++- crates/handlers/src/oauth2/introspection.rs | 88 +++++++++++++++++--- crates/oauth2-types/src/requests.rs | 3 + crates/storage-pg/src/app_session.rs | 2 +- crates/storage-pg/src/compat/session.rs | 38 ++------- crates/storage-pg/src/oauth2/session.rs | 13 ++- 11 files changed, 132 insertions(+), 101 deletions(-) diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index 93228db39..845d8dde0 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -288,7 +288,7 @@ impl Options { .context("User not found")?; let device = if let Some(device_id) = device_id { - device_id.try_into()? + device_id.into() } else { Device::generate(&mut rng) }; diff --git a/crates/data-model/src/compat/device.rs b/crates/data-model/src/compat/device.rs index 49bdc8960..0e2c24f23 100644 --- a/crates/data-model/src/compat/device.rs +++ b/crates/data-model/src/compat/device.rs @@ -22,21 +22,22 @@ pub struct Device { } #[derive(Debug, Error)] -pub enum InvalidDeviceID { - #[error("Device ID contains invalid characters")] +pub enum ToScopeTokenError { + #[error("Device ID contains characters that can't be encoded in a scope")] InvalidCharacters, } impl Device { /// Get the corresponding [`ScopeToken`] for that device - #[must_use] - pub fn to_scope_token(&self) -> ScopeToken { - // SAFETY: the inner id should only have valid scope characters - let Ok(scope_token) = format!("{DEVICE_SCOPE_PREFIX}{}", self.id).parse() else { - unreachable!() - }; - - scope_token + /// + /// # Errors + /// + /// Returns an error if the device ID contains characters that can't be + /// encoded in a scope + pub fn to_scope_token(&self) -> Result { + format!("{DEVICE_SCOPE_PREFIX}{}", self.id) + .parse() + .map_err(|_| ToScopeTokenError::InvalidCharacters) } /// Get the corresponding [`Device`] from a [`ScopeToken`] @@ -45,8 +46,7 @@ impl Device { #[must_use] pub fn from_scope_token(token: &ScopeToken) -> Option { let id = token.as_str().strip_prefix(DEVICE_SCOPE_PREFIX)?; - // XXX: we might be silently ignoring errors here, but it's probably fine? - Device::try_from(id.to_owned()).ok() + Some(Device::from(id.to_owned())) } /// Generate a random device ID @@ -62,39 +62,15 @@ impl Device { } } -const fn valid_device_chars(c: char) -> bool { - // This matches the regex in the policy - c.is_ascii_alphanumeric() - || c == '.' - || c == '_' - || c == '~' - || c == '!' - || c == '$' - || c == '&' - || c == '\'' - || c == '(' - || c == ')' - || c == '*' - || c == '+' - || c == ',' - || c == ';' - || c == '=' - || c == ':' - || c == '@' - || c == '/' - || c == '-' +impl From for Device { + fn from(id: String) -> Self { + Self { id } + } } -impl TryFrom for Device { - type Error = InvalidDeviceID; - - /// Create a [`Device`] out of an ID, validating the ID has the right shape - fn try_from(id: String) -> Result { - if !id.chars().all(valid_device_chars) { - return Err(InvalidDeviceID::InvalidCharacters); - } - - Ok(Self { id }) +impl From for String { + fn from(device: Device) -> Self { + device.id } } @@ -112,8 +88,8 @@ mod test { #[test] fn test_device_id_to_from_scope_token() { - let device = Device::try_from("AABBCCDDEE".to_owned()).unwrap(); - let scope_token = device.to_scope_token(); + let device = Device::from("AABBCCDDEE".to_owned()); + let scope_token = device.to_scope_token().unwrap(); assert_eq!( scope_token.as_str(), "urn:matrix:org.matrix.msc2967.client:device:AABBCCDDEE" diff --git a/crates/data-model/src/compat/mod.rs b/crates/data-model/src/compat/mod.rs index c3f7142d1..c50d74261 100644 --- a/crates/data-model/src/compat/mod.rs +++ b/crates/data-model/src/compat/mod.rs @@ -12,7 +12,7 @@ mod session; mod sso_login; pub use self::{ - device::Device, + device::{Device, ToScopeTokenError}, session::{CompatSession, CompatSessionState}, sso_login::{CompatSsoLogin, CompatSsoLoginState}, }; diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index b26f74f1b..46c04410c 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -26,7 +26,7 @@ pub use ulid::Ulid; pub use self::{ compat::{ CompatAccessToken, CompatRefreshToken, CompatRefreshTokenState, CompatSession, - CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device, + CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device, ToScopeTokenError, }, oauth2::{ AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, DeviceCodeGrant, diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index b98770379..467e6e835 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -229,7 +229,7 @@ impl CompatSession { Self { id: Ulid::from_bytes([0x01; 16]), user_id: Ulid::from_bytes([0x01; 16]), - device_id: Some("AABBCCDDEE".to_owned().try_into().unwrap()), + device_id: Some("AABBCCDDEE".to_owned().into()), user_session_id: Some(Ulid::from_bytes([0x11; 16])), redirect_uri: Some("https://example.com/redirect".parse().unwrap()), created_at: DateTime::default(), @@ -241,7 +241,7 @@ impl CompatSession { Self { id: Ulid::from_bytes([0x02; 16]), user_id: Ulid::from_bytes([0x01; 16]), - device_id: Some("FFGGHHIIJJ".to_owned().try_into().unwrap()), + device_id: Some("FFGGHHIIJJ".to_owned().into()), user_session_id: Some(Ulid::from_bytes([0x12; 16])), redirect_uri: None, created_at: DateTime::default(), diff --git a/crates/handlers/src/graphql/query/session.rs b/crates/handlers/src/graphql/query/session.rs index dba916dff..6f00d50eb 100644 --- a/crates/handlers/src/graphql/query/session.rs +++ b/crates/handlers/src/graphql/query/session.rs @@ -44,9 +44,7 @@ impl SessionQuery { return Ok(None); } - let Ok(device) = Device::try_from(device_id) else { - return Ok(None); - }; + let device = Device::from(device_id); let state = ctx.state(); let mut repo = state.repository().await?; @@ -81,7 +79,14 @@ impl SessionQuery { // Then, try to find an OAuth 2.0 session. Because we don't have any dedicated // device column, we're looking up using the device scope. - let scope = Scope::from_iter([device.to_scope_token()]); + // All device IDs can't necessarily be encoded as a scope. If it's not the case, + // we'll skip looking for OAuth 2.0 sessions. + let Ok(scope_token) = device.to_scope_token() else { + repo.cancel().await?; + + return Ok(None); + }; + let scope = Scope::from_iter([scope_token]); let filter = OAuth2SessionFilter::new() .for_user(&user) .active_only() diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index 0b187278f..945c20ad2 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -4,8 +4,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use axum::{extract::State, response::IntoResponse, Json}; -use hyper::StatusCode; +use axum::{extract::State, http::HeaderValue, response::IntoResponse, Json}; +use hyper::{HeaderMap, StatusCode}; use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, sentry::SentryEventID, @@ -74,6 +74,10 @@ pub enum RouteError { #[error("unknown compat session")] CantLoadCompatSession, + /// The Device ID in the compat session can't be encoded as a scope + #[error("device ID contains characters that are not allowed in a scope")] + CantEncodeDeviceID(#[from] mas_data_model::ToScopeTokenError), + #[error("invalid user")] InvalidUser, @@ -120,7 +124,8 @@ impl IntoResponse for RouteError { | Self::InvalidUser | Self::InvalidCompatSession | Self::InvalidOAuthSession - | Self::InvalidTokenFormat(_) => Json(INACTIVE).into_response(), + | Self::InvalidTokenFormat(_) + | Self::CantEncodeDeviceID(_) => Json(INACTIVE).into_response(), Self::NotAllowed => ( StatusCode::UNAUTHORIZED, Json(ClientError::from(ClientErrorCode::AccessDenied)), @@ -152,6 +157,7 @@ const INACTIVE: IntrospectionResponse = IntrospectionResponse { aud: None, iss: None, jti: None, + device_id: None, }; const API_SCOPE: ScopeToken = ScopeToken::from_static("urn:matrix:org.matrix.msc2967.client:api:*"); @@ -170,6 +176,7 @@ pub(crate) async fn post( mut repo: BoxRepository, activity_tracker: ActivityTracker, State(encrypter): State, + headers: HeaderMap, client_authorization: ClientAuthorization, ) -> Result { let client = client_authorization @@ -202,6 +209,16 @@ pub(crate) async fn post( } } + // Not all device IDs can be encoded as scope. On OAuth 2.0 sessions, we + // don't have this problem, as the device ID *is* already encoded as a scope. + // But on compatibility sessions, it's possible to have device IDs with + // spaces in them, or other weird characters. + // In those cases, we prefer explicitly giving out the device ID as a separate + // field. The client introspecting tells us whether it supports having the + // device ID as a separate field through this header. + let supports_explicit_device_id = + headers.get("X-MAS-Supports-Device-Id") == Some(&HeaderValue::from_static("1")); + // XXX: we should get the IP from the client introspecting the token let ip = None; @@ -270,6 +287,7 @@ pub(crate) async fn post( aud: None, iss: None, jti: Some(access_token.jti()), + device_id: None, } } @@ -329,6 +347,7 @@ pub(crate) async fn post( aud: None, iss: None, jti: Some(refresh_token.jti()), + device_id: None, } } @@ -365,7 +384,19 @@ pub(crate) async fn post( // Grant the synapse admin scope if the session has the admin flag set. let synapse_admin_scope_opt = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE); - let device_scope_opt = session.device.as_ref().map(Device::to_scope_token); + + // If the client supports explicitly giving the device ID in the response, skip + // encoding it in the scope + let device_scope_opt = if supports_explicit_device_id { + None + } else { + session + .device + .as_ref() + .map(Device::to_scope_token) + .transpose()? + }; + let scope = [API_SCOPE] .into_iter() .chain(device_scope_opt) @@ -389,6 +420,7 @@ pub(crate) async fn post( aud: None, iss: None, jti: None, + device_id: session.device.map(Device::into), } } @@ -425,7 +457,19 @@ pub(crate) async fn post( // Grant the synapse admin scope if the session has the admin flag set. let synapse_admin_scope_opt = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE); - let device_scope_opt = session.device.as_ref().map(Device::to_scope_token); + + // If the client supports explicitly giving the device ID in the response, skip + // encoding it in the scope + let device_scope_opt = if supports_explicit_device_id { + None + } else { + session + .device + .as_ref() + .map(Device::to_scope_token) + .transpose()? + }; + let scope = [API_SCOPE] .into_iter() .chain(device_scope_opt) @@ -449,6 +493,7 @@ pub(crate) async fn post( aud: None, iss: None, jti: None, + device_id: session.device.map(Device::into), } } }; @@ -777,10 +822,30 @@ mod tests { response.assert_status(StatusCode::OK); let response: IntrospectionResponse = response.json(); assert!(response.active); - assert_eq!(response.username, Some("alice".to_owned())); - assert_eq!(response.client_id, Some("legacy".to_owned())); + assert_eq!(response.username.as_deref(), Some("alice")); + assert_eq!(response.client_id.as_deref(), Some("legacy")); assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken)); - assert_eq!(response.scope, Some(expected_scope.clone())); + assert_eq!(response.scope.as_ref(), Some(&expected_scope)); + assert_eq!(response.device_id.as_deref(), Some(device_id)); + + // Check that requesting with X-MAS-Supports-Device-Id removes the device ID + // from the scope but not from the explicit device_id field + let request = Request::post(OAuth2Introspection::PATH) + .basic_auth(&introspecting_client_id, &introspecting_client_secret) + .header("X-MAS-Supports-Device-Id", "1") + .form(json!({ "token": access_token })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: IntrospectionResponse = response.json(); + assert!(response.active); + assert_eq!(response.username.as_deref(), Some("alice")); + assert_eq!(response.client_id.as_deref(), Some("legacy")); + assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken)); + assert_eq!( + response.scope.map(|s| s.to_string()), + Some("urn:matrix:org.matrix.msc2967.client:api:*".to_owned()) + ); + assert_eq!(response.device_id.as_deref(), Some(device_id)); // Do the same request, but with a token_type_hint let request = Request::post(OAuth2Introspection::PATH) @@ -808,10 +873,11 @@ mod tests { response.assert_status(StatusCode::OK); let response: IntrospectionResponse = response.json(); assert!(response.active); - assert_eq!(response.username, Some("alice".to_owned())); - assert_eq!(response.client_id, Some("legacy".to_owned())); + assert_eq!(response.username.as_deref(), Some("alice")); + assert_eq!(response.client_id.as_deref(), Some("legacy")); assert_eq!(response.token_type, Some(OAuthTokenTypeHint::RefreshToken)); - assert_eq!(response.scope, Some(expected_scope.clone())); + assert_eq!(response.scope.as_ref(), Some(&expected_scope)); + assert_eq!(response.device_id.as_deref(), Some(device_id)); // Do the same request, but with a token_type_hint let request = Request::post(OAuth2Introspection::PATH) diff --git a/crates/oauth2-types/src/requests.rs b/crates/oauth2-types/src/requests.rs index dedf6e31e..79273b669 100644 --- a/crates/oauth2-types/src/requests.rs +++ b/crates/oauth2-types/src/requests.rs @@ -786,6 +786,9 @@ pub struct IntrospectionResponse { /// String identifier for the token. pub jti: Option, + + /// MAS extension: explicit device ID + pub device_id: Option, } /// A request to the [Revocation Endpoint]. diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index b812e37e9..1aed090e5 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -588,7 +588,7 @@ mod tests { .unwrap(); let device2 = Device::generate(&mut rng); - let scope = Scope::from_iter([OPENID, device2.to_scope_token()]); + let scope = Scope::from_iter([OPENID, device2.to_scope_token().unwrap()]); // We're moving the clock forward by 1 minute between each session to ensure // we're getting consistent ordering in lists. diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index 7d9fa1264..660749794 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -59,42 +59,28 @@ struct CompatSessionLookup { last_active_ip: Option, } -impl TryFrom for CompatSession { - type Error = DatabaseInconsistencyError; - - fn try_from(value: CompatSessionLookup) -> Result { +impl From for CompatSession { + fn from(value: CompatSessionLookup) -> Self { let id = value.compat_session_id.into(); - let device = value - .device_id - .map(Device::try_from) - .transpose() - .map_err(|e| { - DatabaseInconsistencyError::on("compat_sessions") - .column("device_id") - .row(id) - .source(e) - })?; let state = match value.finished_at { None => CompatSessionState::Valid, Some(finished_at) => CompatSessionState::Finished { finished_at }, }; - let session = CompatSession { + CompatSession { id, state, user_id: value.user_id.into(), user_session_id: value.user_session_id.map(Ulid::from), - device, + device: value.device_id.map(Device::from), human_name: value.human_name, created_at: value.created_at, is_synapse_admin: value.is_synapse_admin, user_agent: value.user_agent.map(UserAgent::parse), last_active_at: value.last_active_at, last_active_ip: value.last_active_ip, - }; - - Ok(session) + } } } @@ -125,16 +111,6 @@ impl TryFrom for (CompatSession, Option Result { let id = value.compat_session_id.into(); - let device = value - .device_id - .map(Device::try_from) - .transpose() - .map_err(|e| { - DatabaseInconsistencyError::on("compat_sessions") - .column("device_id") - .row(id) - .source(e) - })?; let state = match value.finished_at { None => CompatSessionState::Valid, @@ -145,7 +121,7 @@ impl TryFrom for (CompatSession, Option { let Some(res) = res else { return Ok(None) }; - Ok(Some(res.try_into()?)) + Ok(Some(res.into())) } #[tracing::instrument( diff --git a/crates/storage-pg/src/oauth2/session.rs b/crates/storage-pg/src/oauth2/session.rs index a81771e90..a22a35d0c 100644 --- a/crates/storage-pg/src/oauth2/session.rs +++ b/crates/storage-pg/src/oauth2/session.rs @@ -125,10 +125,15 @@ impl Filter for OAuth2SessionFilter<'_> { } })) .add_option(self.device().map(|device| { - Expr::val(device.to_scope_token().to_string()).eq(PgFunc::any(Expr::col(( - OAuth2Sessions::Table, - OAuth2Sessions::ScopeList, - )))) + if let Ok(scope_token) = device.to_scope_token() { + Expr::val(scope_token.to_string()).eq(PgFunc::any(Expr::col(( + OAuth2Sessions::Table, + OAuth2Sessions::ScopeList, + )))) + } else { + // If the device ID can't be encoded as a scope token, match no rows + Expr::val(false).into() + } })) .add_option(self.browser_session().map(|browser_session| { Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserSessionId)) From 1eb6c91dcffd28e1ee94148409c15a1ab6d53cd4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 13:53:42 +0000 Subject: [PATCH 002/112] build(deps-dev): bump typescript from 5.7.3 to 5.8.2 in /frontend Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.7.3 to 5.8.2. - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml) - [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.3...v5.8.2) --- updated-dependencies: - dependency-name: typescript dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d3e0ebd6e..7005df01a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -69,7 +69,7 @@ "storybook": "^8.5.5", "storybook-react-i18next": "^3.2.1", "tailwindcss": "^3.4.17", - "typescript": "^5.7.3", + "typescript": "^5.8.2", "vite": "6.2.0", "vite-plugin-compression": "^0.5.1", "vite-plugin-graphql-codegen": "^3.5.0", @@ -13834,9 +13834,9 @@ } }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "devOptional": true, "license": "Apache-2.0", "bin": { diff --git a/frontend/package.json b/frontend/package.json index 7ded2ff64..18374c937 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -79,7 +79,7 @@ "storybook": "^8.5.5", "storybook-react-i18next": "^3.2.1", "tailwindcss": "^3.4.17", - "typescript": "^5.7.3", + "typescript": "^5.8.2", "vite": "6.2.0", "vite-plugin-compression": "^0.5.1", "vite-plugin-graphql-codegen": "^3.5.0", From 6ad070c9e958fa9c993b5388630829e2868304df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 13:53:51 +0000 Subject: [PATCH 003/112] build(deps-dev): bump happy-dom from 17.1.8 to 17.1.9 in /frontend Bumps [happy-dom](https://github.com/capricorn86/happy-dom) from 17.1.8 to 17.1.9. - [Release notes](https://github.com/capricorn86/happy-dom/releases) - [Commits](https://github.com/capricorn86/happy-dom/compare/v17.1.8...v17.1.9) --- updated-dependencies: - dependency-name: happy-dom dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d3e0ebd6e..f4b8179f2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -57,7 +57,7 @@ "autoprefixer": "^10.4.20", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.10.0", - "happy-dom": "^17.1.8", + "happy-dom": "^17.1.9", "i18next-parser": "^9.3.0", "knip": "^5.45.0", "msw": "^2.7.3", @@ -8994,9 +8994,9 @@ } }, "node_modules/happy-dom": { - "version": "17.1.8", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.1.8.tgz", - "integrity": "sha512-Yxbq/FG79z1rhAf/iB6YM8wO2JB/JDQBy99RiLSs+2siEAi5J05x9eW1nnASHZJbpldjJE2KuFLsLZ+AzX/IxA==", + "version": "17.1.9", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.1.9.tgz", + "integrity": "sha512-HL26ajjMVe/wr3xlzjF0sCPCiAKaZJcIRFZHmG4yKHRJp4YAkHPG5X6GfWxCeDTpOmuHhNiOyNKUoZjjnm0tjw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 7ded2ff64..d2cdce797 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,7 +67,7 @@ "autoprefixer": "^10.4.20", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.10.0", - "happy-dom": "^17.1.8", + "happy-dom": "^17.1.9", "i18next-parser": "^9.3.0", "knip": "^5.45.0", "msw": "^2.7.3", From 208aa5d861a20d8011d10edeb343259ca2ccf6ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 13:53:58 +0000 Subject: [PATCH 004/112] build(deps): bump valibot from 1.0.0-rc.2 to 1.0.0-rc.3 in /frontend Bumps [valibot](https://github.com/fabian-hiller/valibot) from 1.0.0-rc.2 to 1.0.0-rc.3. - [Release notes](https://github.com/fabian-hiller/valibot/releases) - [Commits](https://github.com/fabian-hiller/valibot/compare/v1.0.0-rc.2...v1.0.0-rc.3) --- updated-dependencies: - dependency-name: valibot dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d3e0ebd6e..e6526ad5a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,7 +25,7 @@ "react-dom": "^19.0.0", "react-i18next": "^15.4.1", "swagger-ui-dist": "^5.20.0", - "valibot": "^1.0.0-rc.2", + "valibot": "^1.0.0-rc.3", "vaul": "^1.1.2" }, "devDependencies": { @@ -14132,9 +14132,9 @@ } }, "node_modules/valibot": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-rc.2.tgz", - "integrity": "sha512-Tnnp7dydpihvoUbJiaxuYfsCAgAFKuFMex7PTaI25XSjRWkU70DmJPlAO1W6sF1/WUx4RNWyM2hdmBSMIUSZFA==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-rc.3.tgz", + "integrity": "sha512-LT0REa7Iqx4QGcaHLiTiTkcmJqJ9QdpOy89HALFFBJgejTS64GQFRIbDF7e4f6pauQbo/myfKGmWXCLhMeM6+g==", "license": "MIT", "peerDependencies": { "typescript": ">=5" diff --git a/frontend/package.json b/frontend/package.json index 7ded2ff64..2eeb4b42b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,7 +35,7 @@ "react-dom": "^19.0.0", "react-i18next": "^15.4.1", "swagger-ui-dist": "^5.20.0", - "valibot": "^1.0.0-rc.2", + "valibot": "^1.0.0-rc.3", "vaul": "^1.1.2" }, "devDependencies": { From dcc3c67cc9b5fd549b3fff303c1b2961638801c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:01:49 +0000 Subject: [PATCH 005/112] build(deps): bump EmbarkStudios/cargo-deny-action from 2.0.6 to 2.0.10 Bumps [EmbarkStudios/cargo-deny-action](https://github.com/embarkstudios/cargo-deny-action) from 2.0.6 to 2.0.10. - [Release notes](https://github.com/embarkstudios/cargo-deny-action/releases) - [Commits](https://github.com/embarkstudios/cargo-deny-action/compare/v2.0.6...v2.0.10) --- updated-dependencies: - dependency-name: EmbarkStudios/cargo-deny-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d8d28c6f1..c3b390313 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -153,7 +153,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Run `cargo-deny` - uses: EmbarkStudios/cargo-deny-action@v2.0.6 + uses: EmbarkStudios/cargo-deny-action@v2.0.10 with: rust-version: stable From e71afdae6e25593e520e161274958962eb4d55f1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:01:53 +0000 Subject: [PATCH 006/112] build(deps): bump peter-evans/create-pull-request from 7.0.7 to 7.0.8 Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.7 to 7.0.8. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/v7.0.7...v7.0.8) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/translations-download.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 151f223b7..7eb5e0c22 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -37,7 +37,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@v7.0.7 + uses: peter-evans/create-pull-request@v7.0.8 with: sign-commits: true token: ${{ secrets.BOT_GITHUB_TOKEN }} From 5eee8ca294f3dc9420eadc143588f59582d7e3bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:41:13 +0000 Subject: [PATCH 007/112] build(deps-dev): bump @types/node in /frontend in the types group Bumps the types group in /frontend with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node). Updates `@types/node` from 22.13.8 to 22.13.9 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: types ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d25e84efe..4f702c3de 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -48,7 +48,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.13.8", + "@types/node": "^22.13.9", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", "@types/swagger-ui-dist": "^3.30.5", @@ -5837,9 +5837,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.8.tgz", - "integrity": "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==", + "version": "22.13.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", + "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 5624815e7..b2b10d766 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -58,7 +58,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.13.8", + "@types/node": "^22.13.9", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", "@types/swagger-ui-dist": "^3.30.5", From d05b2a34228180de866e702a4fb2c6959f41bc9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:41:46 +0000 Subject: [PATCH 008/112] build(deps-dev): bump happy-dom from 17.1.9 to 17.2.2 in /frontend Bumps [happy-dom](https://github.com/capricorn86/happy-dom) from 17.1.9 to 17.2.2. - [Release notes](https://github.com/capricorn86/happy-dom/releases) - [Commits](https://github.com/capricorn86/happy-dom/compare/v17.1.9...v17.2.2) --- updated-dependencies: - dependency-name: happy-dom dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d25e84efe..e67d0c8cd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -57,7 +57,7 @@ "autoprefixer": "^10.4.20", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.10.0", - "happy-dom": "^17.1.9", + "happy-dom": "^17.2.2", "i18next-parser": "^9.3.0", "knip": "^5.45.0", "msw": "^2.7.3", @@ -8994,9 +8994,9 @@ } }, "node_modules/happy-dom": { - "version": "17.1.9", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.1.9.tgz", - "integrity": "sha512-HL26ajjMVe/wr3xlzjF0sCPCiAKaZJcIRFZHmG4yKHRJp4YAkHPG5X6GfWxCeDTpOmuHhNiOyNKUoZjjnm0tjw==", + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.2.2.tgz", + "integrity": "sha512-3I1/CrNi780sdOhuhUnFtgTWhloSc3quSZwsylI41jycx8o97M6Y4aQAu0phSexGusT7+59BxATh4L4xiY0HcA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 5624815e7..f8eb947f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,7 +67,7 @@ "autoprefixer": "^10.4.20", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.10.0", - "happy-dom": "^17.1.9", + "happy-dom": "^17.2.2", "i18next-parser": "^9.3.0", "knip": "^5.45.0", "msw": "^2.7.3", From 54e599a8caf61dc8755c56d510c6d4da30ecf1d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:44:18 +0000 Subject: [PATCH 009/112] build(deps-dev): bump the storybook group in /frontend with 6 updates Bumps the storybook group in /frontend with 6 updates: | Package | From | To | | --- | --- | --- | | [@storybook/addon-essentials](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/essentials) | `8.6.3` | `8.6.4` | | [@storybook/addon-interactions](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/interactions) | `8.6.3` | `8.6.4` | | [@storybook/react](https://github.com/storybookjs/storybook/tree/HEAD/code/renderers/react) | `8.6.3` | `8.6.4` | | [@storybook/react-vite](https://github.com/storybookjs/storybook/tree/HEAD/code/frameworks/react-vite) | `8.6.3` | `8.6.4` | | [@storybook/test](https://github.com/storybookjs/storybook/tree/HEAD/code/lib/test) | `8.6.3` | `8.6.4` | | [storybook](https://github.com/storybookjs/storybook/tree/HEAD/code/lib/cli) | `8.6.3` | `8.6.4` | Updates `@storybook/addon-essentials` from 8.6.3 to 8.6.4 - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v8.6.4/code/addons/essentials) Updates `@storybook/addon-interactions` from 8.6.3 to 8.6.4 - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v8.6.4/code/addons/interactions) Updates `@storybook/react` from 8.6.3 to 8.6.4 - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v8.6.4/code/renderers/react) Updates `@storybook/react-vite` from 8.6.3 to 8.6.4 - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v8.6.4/code/frameworks/react-vite) Updates `@storybook/test` from 8.6.3 to 8.6.4 - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v8.6.4/code/lib/test) Updates `storybook` from 8.6.3 to 8.6.4 - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/v8.6.4/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v8.6.4/code/lib/cli) --- updated-dependencies: - dependency-name: "@storybook/addon-essentials" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: storybook - dependency-name: "@storybook/addon-interactions" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: storybook - dependency-name: "@storybook/react" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: storybook - dependency-name: "@storybook/react-vite" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: storybook - dependency-name: "@storybook/test" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: storybook - dependency-name: storybook dependency-type: direct:development update-type: version-update:semver-patch dependency-group: storybook ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 260 ++++++++++++++++++------------------- frontend/package.json | 8 +- 2 files changed, 134 insertions(+), 134 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d25e84efe..220536c8d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,10 +37,10 @@ "@graphql-codegen/cli": "^5.0.5", "@graphql-codegen/client-preset": "^4.6.4", "@graphql-codegen/typescript-msw": "^3.0.0", - "@storybook/addon-essentials": "^8.6.3", - "@storybook/addon-interactions": "^8.6.3", - "@storybook/react": "^8.6.3", - "@storybook/react-vite": "^8.6.3", + "@storybook/addon-essentials": "^8.6.4", + "@storybook/addon-interactions": "^8.6.4", + "@storybook/react": "^8.6.4", + "@storybook/react-vite": "^8.6.4", "@storybook/test": "^8.5.5", "@tanstack/react-query-devtools": "^5.67.1", "@tanstack/router-devtools": "^1.112.6", @@ -4741,9 +4741,9 @@ } }, "node_modules/@storybook/addon-actions": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.3.tgz", - "integrity": "sha512-0UrVqRoZFRFCqjtR8ODacpJNqi47qDUnsnB5F7e93U9ihSrH2edOBBX6frl11XKYA23rzq7jtnviFTVOpWpG7Q==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.4.tgz", + "integrity": "sha512-mCcyfkeb19fJX0dpQqqZCnWBwjVn0/27xcpR0mbm/KW2wTByU6bKFFujgrHsX3ONl97IcIaUnmwwUwBr1ebZXw==", "dev": true, "license": "MIT", "dependencies": { @@ -4758,13 +4758,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/addon-backgrounds": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.3.tgz", - "integrity": "sha512-2mmMpMyUsS8rti2guMR4rk4h5YBLNHidxUqTm+U4nITZFfCXNP76To9hfTczpLTvUEpPxSbPG0sCIeHFaw4NRQ==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.4.tgz", + "integrity": "sha512-lRYGumlYdd1RptQJvOTRMx/q2pDmg2MO5GX4la7VfI8KrUyeuC1ZOSRDEcXeTuAZWJztqmtymg6bB7cAAoxCFA==", "dev": true, "license": "MIT", "dependencies": { @@ -4777,13 +4777,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/addon-controls": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.3.tgz", - "integrity": "sha512-j4Oof3nwjyiO6oNP1bJ98Sz1iZlYhdcgHX284yd0wBO91Q5B2GoCeqyCE+yRCh752ZnnYG1gazJrHmiG6gKxVg==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.4.tgz", + "integrity": "sha512-oMMP9Bj0RMfYmaitjFt6oBSjKH4titUqP+wE6PrZ3v+Om56f4buqfNKXRf80As2OrsZn0pjj95muWzVVHqIhyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4796,20 +4796,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/addon-docs": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.3.tgz", - "integrity": "sha512-FRABH+r2huMpAK8iUQiFlYZtYenbqtudX3fNKFK9b38eV1R14kWggVG02lsa6upXbzxWVbMLUdOqaZJHxNbO/A==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.4.tgz", + "integrity": "sha512-+kbcjvEAH0Xs+k+raAwfC0WmJilWhxBYnLLeazP3m5AkVI3sIjbzuuZ78NR0DCdRkw9BpuuXMHv5o4tIvLIUlw==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/blocks": "8.6.3", - "@storybook/csf-plugin": "8.6.3", - "@storybook/react-dom-shim": "8.6.3", + "@storybook/blocks": "8.6.4", + "@storybook/csf-plugin": "8.6.4", + "@storybook/react-dom-shim": "8.6.4", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -4819,25 +4819,25 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/addon-essentials": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.3.tgz", - "integrity": "sha512-tH+MwkZ6UwRWyhGdq8izVZAZHGWdeiBY1wpIwdceP1Rl2j9s11Gbddb/JlmiXrC+f/Oiylxghaf7EIksVVqLQQ==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.4.tgz", + "integrity": "sha512-3pF0ZDl5EICqe0eOupPQq6PxeupwkLsfTWANuuJUYTJur82kvJd3Chb7P9vqw0A0QBx6106mL6PIyjrFJJMhLg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/addon-actions": "8.6.3", - "@storybook/addon-backgrounds": "8.6.3", - "@storybook/addon-controls": "8.6.3", - "@storybook/addon-docs": "8.6.3", - "@storybook/addon-highlight": "8.6.3", - "@storybook/addon-measure": "8.6.3", - "@storybook/addon-outline": "8.6.3", - "@storybook/addon-toolbars": "8.6.3", - "@storybook/addon-viewport": "8.6.3", + "@storybook/addon-actions": "8.6.4", + "@storybook/addon-backgrounds": "8.6.4", + "@storybook/addon-controls": "8.6.4", + "@storybook/addon-docs": "8.6.4", + "@storybook/addon-highlight": "8.6.4", + "@storybook/addon-measure": "8.6.4", + "@storybook/addon-outline": "8.6.4", + "@storybook/addon-toolbars": "8.6.4", + "@storybook/addon-viewport": "8.6.4", "ts-dedent": "^2.0.0" }, "funding": { @@ -4845,13 +4845,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/addon-highlight": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.3.tgz", - "integrity": "sha512-LYZsgZt5q3EZBkZjUEELh/5+TDnUP0njuQ5g6skyKil6vj9+2RI4/Vjodp+ni5+xct5aDhXavRyUnPRfclX/Cg==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.4.tgz", + "integrity": "sha512-jFREXnSE/7VuBR8kbluN+DBVkMXEV7MGuCe8Ytb1/D2Q0ohgJe395dfVgEgSMXErOwsn//NV/NgJp6JNXH2DrA==", "dev": true, "license": "MIT", "dependencies": { @@ -4862,19 +4862,19 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/addon-interactions": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.6.3.tgz", - "integrity": "sha512-cDvxuMcjoQdtimNrT4BM9AK0qZJhA0Ep/CWPcVK1bAFzqlzBbe//UZa5It/AeC4EMYAr5rFY+LWEli3YPeOnjQ==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.6.4.tgz", + "integrity": "sha512-MZAAZjyvmJXCvM35zEiPpXz7vK+fimovt+WZKAMayAbXy5fT+7El0c9dDyTQ2norNKNj9QU/8hiU/1zARSUELQ==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.6.3", - "@storybook/test": "8.6.3", + "@storybook/instrumenter": "8.6.4", + "@storybook/test": "8.6.4", "polished": "^4.2.2", "ts-dedent": "^2.2.0" }, @@ -4883,13 +4883,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/addon-measure": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.3.tgz", - "integrity": "sha512-FC/3pqM2adSnwyPOd9AxEoZD5XWCMKAk16urQFQ0M4+IzRUdf2OV8cc7aM/oZiBX36+q/UCcUWm2SbQ5nzNJpg==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.4.tgz", + "integrity": "sha512-IpVL1rTy1tO8sy140eU3GdVB1QJ6J62+V6GSstcmqTLxDJQk5jFfg7hVbPEAZZ2sPFmeyceP9AMoBBo0EB355A==", "dev": true, "license": "MIT", "dependencies": { @@ -4901,13 +4901,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/addon-outline": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.3.tgz", - "integrity": "sha512-YklKHRkoDLSWawIIBrEI69RAWEdvhkYCOv+fMLu9zBeVPnkwbtIjXN/I+UJwPCm6jlxeEwEUAvbPWZMMf+BkPQ==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.4.tgz", + "integrity": "sha512-28nAslKTy0zWMdxAZcipMDYrEp1TkXVooAsqMGY5AMXMiORi1ObjhmjTLhVt1dXp+aDg0X+M3B6PqoingmHhqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4919,13 +4919,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/addon-toolbars": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.3.tgz", - "integrity": "sha512-GTC1GPrFNfWvvBaQQnGuL7ZfGK5Q+3ZovwQA9tnPu7QZEwea/4CXvUyQh1u0NwqrFZkrabOad1XvYfpRuCPGSA==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.4.tgz", + "integrity": "sha512-PU2lvgwCKDn93zpp5MEog103UUmSSugcxDf18xaoa9D15Qtr+YuQHd2hXbxA7+dnYL9lA7MLYsstfxE91ieM4Q==", "dev": true, "license": "MIT", "funding": { @@ -4933,13 +4933,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/addon-viewport": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.3.tgz", - "integrity": "sha512-AixZKiQdBVs7ePj5iV0U1IY2jvH0G7wQJwBRTOq4qC1FKiOsZEYmrwc3wLUBUlVqyenXFKN+H40r4VhPzzSfLw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.4.tgz", + "integrity": "sha512-O5Ij+SRVg6grY6JOL5lOpsFyopZxuZEl2GHfh2SUf9hfowNS0QAgFpJupqXkwZzRSrlf9uKrLkjB6ulLgN2gOQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4950,13 +4950,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/blocks": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.3.tgz", - "integrity": "sha512-Ieu6kwqdeAcrLzcX2QIqnCd0XWZi46i4eem8W54JRiOMQMYUpZ7onbciRAP58qxEWrZWqgxPS+tiCTaJe48VVQ==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.4.tgz", + "integrity": "sha512-+oPXwT3KzJzsdkQuGEzBqOKTIFlb6qmlCWWbDwAnP0SEqYHoTVRTAIa44icFP0EZeIe+ypFVAm1E7kWTLmw1hQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4970,7 +4970,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^8.6.3" + "storybook": "^8.6.4" }, "peerDependenciesMeta": { "react": { @@ -4982,13 +4982,13 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.6.3.tgz", - "integrity": "sha512-v/nlBeT7Avn1ld2GHY5dtm1+TKREvtQ+DEcKK5iOWfv2259WqUp0dGnF4fbHcsNCtFurkA/P2uqJ9vc0xOIVUg==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.6.4.tgz", + "integrity": "sha512-FuSP2GhWVVTt6NdX0UJHhPOqhu09X4apSk+KWUf3aITRIJg9gbPYtJDBmxv1vXQEgvfCDdYBYbeG1khiO/Ghfw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "8.6.3", + "@storybook/csf-plugin": "8.6.4", "browser-assert": "^1.2.1", "ts-dedent": "^2.0.0" }, @@ -4997,14 +4997,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3", + "storybook": "^8.6.4", "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" } }, "node_modules/@storybook/components": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.3.tgz", - "integrity": "sha512-q5DQkV+E/j0KfF818RywgqEHjaZTg71q5YY4z0UO8CRSzDQ/VYF6L76oc69corbkJtYAk/GqaYJllzrWykS4sg==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.4.tgz", + "integrity": "sha512-91VEVFWOgHkEFoNFMk6gs1AuOE9Yp7N283BXQOW+AgP+atpzED6t/fIBPGqJ2ewAuzLJ+cFOrasSzoNwVfg3Jg==", "dev": true, "license": "MIT", "funding": { @@ -5016,13 +5016,13 @@ } }, "node_modules/@storybook/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.3.tgz", - "integrity": "sha512-0iMTfmo3UFCa1hFJLtThnRIppkIpGPyTL3MElhORP1t5l9lCUq5am0ymbi/TeCbsJPjE86FjeO0NinokL9iQiw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.4.tgz", + "integrity": "sha512-glDbjEBi3wokw1T+KQtl93irHO9N0LCwgylWfWVXYDdQjUJ7pGRQGnw73gPX7Ds9tg3myXFC83GjmY94UYSMbA==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/theming": "8.6.3", + "@storybook/theming": "8.6.4", "better-opn": "^3.0.2", "browser-assert": "^1.2.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", @@ -5048,9 +5048,9 @@ } }, "node_modules/@storybook/csf-plugin": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.3.tgz", - "integrity": "sha512-0QDLBcMOxSEt1yH28cvIsoiaIokIxDDShMnxVJHWk/7+KZ3xe4lZBfKCWZspZoJmrxgz10gLRifj1b3ysIFlyA==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.4.tgz", + "integrity": "sha512-7UpEp4PFTy1iKjZiRaYMG7zvnpLIRPyD0+lUJUlLYG4UIemV3onvnIi1Je1tSZ4hfTup+ulom7JLztVSHZGRMg==", "dev": true, "license": "MIT", "dependencies": { @@ -5061,7 +5061,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/global": { @@ -5086,9 +5086,9 @@ } }, "node_modules/@storybook/instrumenter": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.3.tgz", - "integrity": "sha512-Y5n6JWCWdOqok08Hgklsc98TBoqROhAhBRSzNWuIaLsRhz8EziXQtuEkWqmVbyYOys25iTZiK3S8+QQkOzGrBw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.4.tgz", + "integrity": "sha512-8OtIWLhayTUdqJEeXiPm6l3LTdSkWgQzzV2l2HIe4Adedeot+Rkwu6XHmyRDpnb0+Ish6zmMDqtJBxC2PQsy6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5100,13 +5100,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/manager-api": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.3.tgz", - "integrity": "sha512-7m9MQELc6XpuKIuliqMiQWzl8yVWpUDwTcpr+rTT7l3OfRzw7Y00UFct2tI03YG6EXsxsykw8EmueMQhe0lG5Q==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.4.tgz", + "integrity": "sha512-w/Nn/VznfbIg2oezDfzZNwSTDY5kBZbzxVBHLCnIcyu2AKt2Yto3pfGi60SikFcTrsClaAKT7D92kMQ9qdQNQQ==", "dev": true, "license": "MIT", "funding": { @@ -5118,9 +5118,9 @@ } }, "node_modules/@storybook/preview-api": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.3.tgz", - "integrity": "sha512-y2Ic6eHBQD/AwaCHctKOJ4tOM1r7/mPXfhGh0I+Qf8kZPlDTgQcJ6Z7/Ruma1L+ijXPBWouDaPw51gipcX+t9Q==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.4.tgz", + "integrity": "sha512-5HBfxggzxGz0dg2c61NpPiQJav7UAmzsQlzmI5SzWOS6lkaylcDG8giwKzASVCXVWBxNji9qIDFM++UH090aDg==", "dev": true, "license": "MIT", "funding": { @@ -5132,18 +5132,18 @@ } }, "node_modules/@storybook/react": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.6.3.tgz", - "integrity": "sha512-B4WYRWU2Y71UWl4CG3+mcB7duNln9finJyDB8Y1o2CYWUxgEo+3Bnp3k7NUr++tYVkZI1H+28UWeX0rpCkvReQ==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.6.4.tgz", + "integrity": "sha512-pfv4hMhu3AScOh0l86uIzmXLSQ0XA/e0reIVwQcxKht6miaKArhx9GkS4mMp6SO23ZoV5G/nfLgUaMVPVE0ZPg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/components": "8.6.3", + "@storybook/components": "8.6.4", "@storybook/global": "^5.0.0", - "@storybook/manager-api": "8.6.3", - "@storybook/preview-api": "8.6.3", - "@storybook/react-dom-shim": "8.6.3", - "@storybook/theming": "8.6.3" + "@storybook/manager-api": "8.6.4", + "@storybook/preview-api": "8.6.4", + "@storybook/react-dom-shim": "8.6.4", + "@storybook/theming": "8.6.4" }, "engines": { "node": ">=18.0.0" @@ -5153,10 +5153,10 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "@storybook/test": "8.6.3", + "@storybook/test": "8.6.4", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.3", + "storybook": "^8.6.4", "typescript": ">= 4.2.x" }, "peerDependenciesMeta": { @@ -5169,9 +5169,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.3.tgz", - "integrity": "sha512-vE3LA2TxbzDF1Fso2IgvUtoHc+8a6laKhuJdx8frP5A8M1KGOBfuEPFCCcE49Q90HUlDgwb/zQl1GNq/QjLgWQ==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.4.tgz", + "integrity": "sha512-kTGJ3aFdmfCFzYaDFGmZWfTXr9xhbUaf0tJ6+nEjc4tME6mFwMI+tTUT6U/J6mJhZuc2DjvIRA7bM0x77dIDqw==", "dev": true, "license": "MIT", "funding": { @@ -5181,20 +5181,20 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/react-vite": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.6.3.tgz", - "integrity": "sha512-A/cA0wM/mMfFcJH7dxhWSbVg9aE2zZKNDioyEbiB042CgrLW3zQ6dvQvA5ohFhsPWZ6GVAyc+r3x0JE55aXxWQ==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.6.4.tgz", + "integrity": "sha512-MEmD6sP2tUI/SYCXCeWGTs8umZj+N0e3DHXCQUz0nCsJH7kuCTTipOTBQvr/GuEstNd7BNG5k8aLIRrXLjAvdA==", "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "0.5.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "8.6.3", - "@storybook/react": "8.6.3", + "@storybook/builder-vite": "8.6.4", + "@storybook/react": "8.6.4", "find-up": "^5.0.0", "magic-string": "^0.30.0", "react-docgen": "^7.0.0", @@ -5209,10 +5209,10 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "@storybook/test": "8.6.3", + "@storybook/test": "8.6.4", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.3", + "storybook": "^8.6.4", "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" }, "peerDependenciesMeta": { @@ -5222,14 +5222,14 @@ } }, "node_modules/@storybook/test": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.3.tgz", - "integrity": "sha512-UimvhV/PmYoXCwIbGpkyqQfMhjdH2GaHJbV6BWr7M7BHA3kUS6zYJAm2V2CC5SYcmyj7FejLB4tgL7FmLXB6hA==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.4.tgz", + "integrity": "sha512-JPjfbaMMuCBT47pg3/MDD9vYFF5OGPAOWEB9nJWJ9IjYAb2Nd8OYJQIDoYJQNT+aLkTVLtvzGnVNwdxpouAJcQ==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.6.3", + "@storybook/instrumenter": "8.6.4", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/user-event": "14.5.2", @@ -5241,7 +5241,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.6.3" + "storybook": "^8.6.4" } }, "node_modules/@storybook/test/node_modules/@testing-library/jest-dom": { @@ -5301,9 +5301,9 @@ "license": "MIT" }, "node_modules/@storybook/theming": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.3.tgz", - "integrity": "sha512-sDcWnnko73KOCIc9stQyec9KvTmGOuMswqeKtWh0ha/wsgYB6G2/2j1xOheFmWKPitOsbwgvqtjCP7bRE68uIA==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.4.tgz", + "integrity": "sha512-g9Ns4uenC9oAWETaJ/tEKEIPMdS+CqjNWZz5Wbw1bLNhXwADZgKrVqawzZi64+bYYtQ+i8VCTjPoFa6s2eHiDQ==", "dev": true, "license": "MIT", "funding": { @@ -7085,14 +7085,14 @@ } }, "node_modules/call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -12615,13 +12615,13 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.3.tgz", - "integrity": "sha512-Vbmd8/FXp6X0AOMak6arcg3WdkHj+2AYJTNHbCPVHsCEbnREyRZIG+Eq5/Ffmy6byiz+4OAX5HwsHGSMR6Xmow==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.4.tgz", + "integrity": "sha512-XXh1Acvf1r3BQX0BDLQw6yhZ7yUGvYxIcKOBuMdetnX7iXtczipJTfw0uyFwk0ltkKEE9PpJvivYmARF3u64VQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core": "8.6.3" + "@storybook/core": "8.6.4" }, "bin": { "getstorybook": "bin/index.cjs", diff --git a/frontend/package.json b/frontend/package.json index 5624815e7..74981b00c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,10 +47,10 @@ "@graphql-codegen/cli": "^5.0.5", "@graphql-codegen/client-preset": "^4.6.4", "@graphql-codegen/typescript-msw": "^3.0.0", - "@storybook/addon-essentials": "^8.6.3", - "@storybook/addon-interactions": "^8.6.3", - "@storybook/react": "^8.6.3", - "@storybook/react-vite": "^8.6.3", + "@storybook/addon-essentials": "^8.6.4", + "@storybook/addon-interactions": "^8.6.4", + "@storybook/react": "^8.6.4", + "@storybook/react-vite": "^8.6.4", "@storybook/test": "^8.5.5", "@tanstack/react-query-devtools": "^5.67.1", "@tanstack/router-devtools": "^1.112.6", From 7ffc9671d960fb81f874c93bd3845c0403656c4d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:44:56 +0000 Subject: [PATCH 010/112] build(deps): bump @vector-im/compound-design-tokens in /frontend Bumps [@vector-im/compound-design-tokens](https://github.com/vector-im/compound-design-tokens) from 4.0.0 to 4.0.1. - [Release notes](https://github.com/vector-im/compound-design-tokens/releases) - [Changelog](https://github.com/element-hq/compound-design-tokens/blob/main/docs/release.md) - [Commits](https://github.com/vector-im/compound-design-tokens/compare/v4.0.0...v4.0.1) --- updated-dependencies: - dependency-name: "@vector-im/compound-design-tokens" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d25e84efe..c97561179 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,7 @@ "@radix-ui/react-dialog": "^1.1.6", "@tanstack/react-query": "^5.67.1", "@tanstack/react-router": "^1.112.0", - "@vector-im/compound-design-tokens": "4.0.0", + "@vector-im/compound-design-tokens": "4.0.1", "@vector-im/compound-web": "^7.6.4", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", @@ -5919,9 +5919,9 @@ } }, "node_modules/@vector-im/compound-design-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@vector-im/compound-design-tokens/-/compound-design-tokens-4.0.0.tgz", - "integrity": "sha512-hFfLSKrGc58rPRp9JH1mkgw3moFEgpL8RQzyDESHErq7P1lUmlIuwKFTVfK5SbdFM5GvHp7nQaFpVmxUQ3Xp+w==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@vector-im/compound-design-tokens/-/compound-design-tokens-4.0.1.tgz", + "integrity": "sha512-V4AsK1FVFxZ6DmmCoeAi8FyvE7ODMlXPWjqRGotcnVaoGNrDQrVz2ZGV85DCz5ISxB3iynYASe6OXsDVXT1zFA==", "license": "SEE LICENSE IN README.md", "peerDependencies": { "@types/react": "*", diff --git a/frontend/package.json b/frontend/package.json index 5624815e7..c9cf35174 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ "@radix-ui/react-dialog": "^1.1.6", "@tanstack/react-query": "^5.67.1", "@tanstack/react-router": "^1.112.0", - "@vector-im/compound-design-tokens": "4.0.0", + "@vector-im/compound-design-tokens": "4.0.1", "@vector-im/compound-web": "^7.6.4", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", From 629a194c359450f1f932299b708421b48d3f9d7a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 6 Mar 2025 17:37:54 +0100 Subject: [PATCH 011/112] Require the user password to add or remove an email address --- .../src/graphql/mutations/user_email.rs | 127 ++++++++++++++++-- frontend/schema.graphql | 18 +++ frontend/src/gql/graphql.ts | 14 ++ 3 files changed, 149 insertions(+), 10 deletions(-) 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/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/gql/graphql.ts b/frontend/src/gql/graphql.ts index b0b581717..72d581dfc 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 */ From 018e9b82f6af55fca2d473848a46b90c9918b1cb Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 7 Mar 2025 10:08:05 +0100 Subject: [PATCH 012/112] Confirm account password before adding/removing email addresses --- frontend/locales/en.json | 10 +- .../src/components/PasswordConfirmation.tsx | 105 +++++++++ .../components/UserEmail/UserEmail.module.css | 1 + .../src/components/UserEmail/UserEmail.tsx | 206 +++++++++++------- .../components/UserProfile/AddEmailForm.tsx | 166 +++++++++----- .../components/UserProfile/UserEmailList.tsx | 19 +- frontend/src/gql/gql.ts | 48 ++-- frontend/src/gql/graphql.ts | 69 ++++-- frontend/src/routes/_account.index.lazy.tsx | 13 +- frontend/src/routes/_account.index.tsx | 4 +- frontend/stories/routes/index.stories.tsx | 40 +++- frontend/tests/mocks/handlers.ts | 42 +++- .../account/__snapshots__/index.test.tsx.snap | 96 ++++---- 13 files changed, 576 insertions(+), 243 deletions(-) create mode 100644 frontend/src/components/PasswordConfirmation.tsx 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/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 72d581dfc..b55a67402 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1654,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; }>; @@ -1675,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']; }>; @@ -1696,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<{ @@ -2161,11 +2170,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 @@ -2180,9 +2184,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(` @@ -2257,8 +2277,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 @@ -2274,8 +2294,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 { @@ -2318,6 +2340,8 @@ export const UserProfileDocument = new TypedDocumentString(` ... on BrowserSession { id user { + ...AddEmailForm_user + ...UserEmailList_user hasPassword emails(first: 0) { totalCount @@ -2328,19 +2352,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) { @@ -2875,7 +2906,7 @@ export const mockEndOAuth2SessionMutation = (resolver: GraphQLResponseResolver { - * const { id } = variables; + * const { id, password } = variables; * return HttpResponse.json({ * data: { removeEmail } * }) @@ -2919,7 +2950,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 + } + > + + {t("frontend.account.delete_account.dialog_title")} + + + + , + list:
    , + item: , + profile: ( + + ), + }} + /> + + + + } name="hs-erase"> + + {t("frontend.account.delete_account.erase_checkbox_label")} + + + + + + {shouldPromptPassword ? ( + + + {t("frontend.account.delete_account.password_label")} + + + + + + {t("frontend.errors.field_required")} + + + {incorrectPassword && ( + + {t("frontend.account.delete_account.incorrect_password")} + + )} + + ) : ( + + + {t("frontend.account.delete_account.mxid_label", { + mxid: user.matrix.mxid, + })} + + + + + + {t("frontend.errors.field_required")} + + + value !== user.matrix.mxid}> + {t("frontend.account.delete_account.mxid_mismatch")} + + + )} + + {isMaybeValid && ( + + {t("frontend.account.delete_account.alert_description")} + + )} + + + + + + + + + ); +}; + +export default AccountDeleteButton; diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 5a0aa8aad..bb1e9adb2 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -15,6 +15,9 @@ import * as types from './graphql'; * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size */ type Documents = { + "\n fragment AccountDeleteButton_user on User {\n username\n hasPassword\n matrix {\n mxid\n displayName\n }\n }\n": typeof types.AccountDeleteButton_UserFragmentDoc, + "\n fragment AccountDeleteButton_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": typeof types.AccountDeleteButton_SiteConfigFragmentDoc, + "\n mutation DeactivateUser($hsErase: Boolean!, $password: String) {\n deactivateUser(input: { hsErase: $hsErase, password: $password }) {\n status\n }\n }\n": typeof types.DeactivateUserDocument, "\n fragment PasswordChange_siteConfig on SiteConfig {\n passwordChangeAllowed\n }\n": typeof types.PasswordChange_SiteConfigFragmentDoc, "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n deviceType\n name\n os\n model\n }\n lastActiveAt\n }\n": typeof types.BrowserSession_SessionFragmentDoc, "\n fragment OAuth2Client_detail on Oauth2Client {\n id\n clientId\n clientName\n clientUri\n logoUri\n tosUri\n policyUri\n redirectUris\n }\n": typeof types.OAuth2Client_DetailFragmentDoc, @@ -44,7 +47,7 @@ type Documents = { "\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 ...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 UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_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, @@ -66,6 +69,9 @@ type Documents = { "\n query SessionDetail($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": typeof types.SessionDetailDocument, }; const documents: Documents = { + "\n fragment AccountDeleteButton_user on User {\n username\n hasPassword\n matrix {\n mxid\n displayName\n }\n }\n": types.AccountDeleteButton_UserFragmentDoc, + "\n fragment AccountDeleteButton_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": types.AccountDeleteButton_SiteConfigFragmentDoc, + "\n mutation DeactivateUser($hsErase: Boolean!, $password: String) {\n deactivateUser(input: { hsErase: $hsErase, password: $password }) {\n status\n }\n }\n": types.DeactivateUserDocument, "\n fragment PasswordChange_siteConfig on SiteConfig {\n passwordChangeAllowed\n }\n": types.PasswordChange_SiteConfigFragmentDoc, "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n deviceType\n name\n os\n model\n }\n lastActiveAt\n }\n": types.BrowserSession_SessionFragmentDoc, "\n fragment OAuth2Client_detail on Oauth2Client {\n id\n clientId\n clientName\n clientUri\n logoUri\n tosUri\n policyUri\n redirectUris\n }\n": types.OAuth2Client_DetailFragmentDoc, @@ -95,7 +101,7 @@ const documents: Documents = { "\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 ...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 UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_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, @@ -117,6 +123,18 @@ const documents: Documents = { "\n query SessionDetail($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": types.SessionDetailDocument, }; +/** + * 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 AccountDeleteButton_user on User {\n username\n hasPassword\n matrix {\n mxid\n displayName\n }\n }\n"): typeof import('./graphql').AccountDeleteButton_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 AccountDeleteButton_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n"): typeof import('./graphql').AccountDeleteButton_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 DeactivateUser($hsErase: Boolean!, $password: String) {\n deactivateUser(input: { hsErase: $hsErase, password: $password }) {\n status\n }\n }\n"): typeof import('./graphql').DeactivateUserDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -236,7 +254,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 ...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; +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 ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_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 882bba82b..46df85274 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1617,6 +1617,18 @@ export type Viewer = Anonymous | User; /** Represents the current viewer's session */ export type ViewerSession = Anonymous | BrowserSession | Oauth2Session; +export type AccountDeleteButton_UserFragment = { __typename?: 'User', username: string, hasPassword: boolean, matrix: { __typename?: 'MatrixUser', mxid: string, displayName?: string | null } } & { ' $fragmentName'?: 'AccountDeleteButton_UserFragment' }; + +export type AccountDeleteButton_SiteConfigFragment = { __typename?: 'SiteConfig', passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'AccountDeleteButton_SiteConfigFragment' }; + +export type DeactivateUserMutationVariables = Exact<{ + hsErase: Scalars['Boolean']['input']; + password?: InputMaybe; +}>; + + +export type DeactivateUserMutation = { __typename?: 'Mutation', deactivateUser: { __typename?: 'DeactivateUserPayload', status: DeactivateUserStatus } }; + export type PasswordChange_SiteConfigFragment = { __typename?: 'SiteConfig', passwordChangeAllowed: boolean } & { ' $fragmentName'?: 'PasswordChange_SiteConfigFragment' }; export type BrowserSession_SessionFragment = ( @@ -1749,10 +1761,10 @@ 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 } } - & { ' $fragmentRefs'?: { 'AddEmailForm_UserFragment': AddEmailForm_UserFragment;'UserEmailList_UserFragment': UserEmailList_UserFragment } } + & { ' $fragmentRefs'?: { 'AddEmailForm_UserFragment': AddEmailForm_UserFragment;'UserEmailList_UserFragment': UserEmailList_UserFragment;'AccountDeleteButton_UserFragment': AccountDeleteButton_UserFragment } } ) } | { __typename: 'Oauth2Session' }, siteConfig: ( - { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean } - & { ' $fragmentRefs'?: { 'AddEmailForm_SiteConfigFragment': AddEmailForm_SiteConfigFragment;'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment } } + { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean, accountDeactivationAllowed: boolean } + & { ' $fragmentRefs'?: { 'AddEmailForm_SiteConfigFragment': AddEmailForm_SiteConfigFragment;'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment;'AccountDeleteButton_SiteConfigFragment': AccountDeleteButton_SiteConfigFragment } } ) }; export type BrowserSessionListQueryVariables = Exact<{ @@ -1944,6 +1956,21 @@ export class TypedDocumentString return this.value; } } +export const AccountDeleteButton_UserFragmentDoc = new TypedDocumentString(` + fragment AccountDeleteButton_user on User { + username + hasPassword + matrix { + mxid + displayName + } +} + `, {"fragmentName":"AccountDeleteButton_user"}) as unknown as TypedDocumentString; +export const AccountDeleteButton_SiteConfigFragmentDoc = new TypedDocumentString(` + fragment AccountDeleteButton_siteConfig on SiteConfig { + passwordLoginEnabled +} + `, {"fragmentName":"AccountDeleteButton_siteConfig"}) as unknown as TypedDocumentString; export const PasswordChange_SiteConfigFragmentDoc = new TypedDocumentString(` fragment PasswordChange_siteConfig on SiteConfig { passwordChangeAllowed @@ -2275,6 +2302,13 @@ export const RecoverPassword_SiteConfigFragmentDoc = new TypedDocumentString(` id minimumPasswordComplexity }`, {"fragmentName":"RecoverPassword_siteConfig"}) as unknown as TypedDocumentString; +export const DeactivateUserDocument = new TypedDocumentString(` + mutation DeactivateUser($hsErase: Boolean!, $password: String) { + deactivateUser(input: {hsErase: $hsErase, password: $password}) { + status + } +} + `) as unknown as TypedDocumentString; export const FooterDocument = new TypedDocumentString(` query Footer { siteConfig { @@ -2384,6 +2418,7 @@ export const UserProfileDocument = new TypedDocumentString(` user { ...AddEmailForm_user ...UserEmailList_user + ...AccountDeleteButton_user hasPassword emails(first: 0) { totalCount @@ -2394,12 +2429,25 @@ export const UserProfileDocument = new TypedDocumentString(` siteConfig { emailChangeAllowed passwordLoginEnabled + accountDeactivationAllowed ...AddEmailForm_siteConfig ...UserEmailList_siteConfig ...PasswordChange_siteConfig + ...AccountDeleteButton_siteConfig } } - fragment PasswordChange_siteConfig on SiteConfig { + fragment AccountDeleteButton_user on User { + username + hasPassword + matrix { + mxid + displayName + } +} +fragment AccountDeleteButton_siteConfig on SiteConfig { + passwordLoginEnabled +} +fragment PasswordChange_siteConfig on SiteConfig { passwordChangeAllowed } fragment AddEmailForm_user on User { @@ -2854,6 +2902,28 @@ fragment OAuth2Session_detail on Oauth2Session { } }`) as unknown as TypedDocumentString; +/** + * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) + * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) + * @see https://mswjs.io/docs/basics/response-resolver + * @example + * mockDeactivateUserMutation( + * ({ query, variables }) => { + * const { hsErase, password } = variables; + * return HttpResponse.json({ + * data: { deactivateUser } + * }) + * }, + * requestOptions + * ) + */ +export const mockDeactivateUserMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'DeactivateUser', + resolver, + options + ) + /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) diff --git a/frontend/src/routes/_account.index.lazy.tsx b/frontend/src/routes/_account.index.lazy.tsx index ce494e77c..ec24f2dfe 100644 --- a/frontend/src/routes/_account.index.lazy.tsx +++ b/frontend/src/routes/_account.index.lazy.tsx @@ -13,6 +13,7 @@ import { import IconSignOut from "@vector-im/compound-design-tokens/assets/web/icons/sign-out"; import { Button, Text } from "@vector-im/compound-web"; import { useTranslation } from "react-i18next"; +import AccountDeleteButton from "../components/AccountDeleteButton"; import AccountManagementPasswordPreview from "../components/AccountManagementPasswordPreview"; import { ButtonLink } from "../components/ButtonLink"; import * as Collapsible from "../components/Collapsible"; @@ -127,9 +128,21 @@ function Index(): React.ReactElement { - - + + + {siteConfig.accountDeactivationAllowed && ( + <> + + + + )} + + + ); } diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index 0e0e19bb2..bdeb470fa 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -20,6 +20,7 @@ const QUERY = graphql(/* GraphQL */ ` user { ...AddEmailForm_user ...UserEmailList_user + ...AccountDeleteButton_user hasPassword emails(first: 0) { totalCount @@ -31,9 +32,11 @@ const QUERY = graphql(/* GraphQL */ ` siteConfig { emailChangeAllowed passwordLoginEnabled + accountDeactivationAllowed ...AddEmailForm_siteConfig ...UserEmailList_siteConfig ...PasswordChange_siteConfig + ...AccountDeleteButton_siteConfig } } `); diff --git a/frontend/stories/routes/index.stories.tsx b/frontend/stories/routes/index.stories.tsx index a9783825c..82150741e 100644 --- a/frontend/stories/routes/index.stories.tsx +++ b/frontend/stories/routes/index.stories.tsx @@ -7,6 +7,10 @@ import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, waitFor, within } from "@storybook/test"; import i18n from "i18next"; import { type GraphQLHandler, HttpResponse } from "msw"; +import { + CONFIG_FRAGMENT as ACCOUNT_DELETE_BUTTON_CONFIG_FRAGMENT, + USER_FRAGMENT as ACCOUNT_DELETE_BUTTON_USER_FRAGMENT, +} from "../../src/components/AccountDeleteButton"; 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 { @@ -38,12 +42,14 @@ const userProfileHandler = ({ passwordLoginEnabled, passwordChangeAllowed, emailTotalCount, + accountDeactivationAllowed, hasPassword, }: { emailChangeAllowed: boolean; passwordLoginEnabled: boolean; passwordChangeAllowed: boolean; emailTotalCount: number; + accountDeactivationAllowed: boolean; hasPassword: boolean; }): GraphQLHandler => mockUserProfileQuery(() => @@ -71,6 +77,17 @@ const userProfileHandler = ({ }, USER_EMAIL_LIST_USER_FRAGMENT, ), + makeFragmentData( + { + hasPassword, + username: "alice", + matrix: { + displayName: "Alice", + mxid: "@alice:example.com", + }, + }, + ACCOUNT_DELETE_BUTTON_USER_FRAGMENT, + ), ), }, @@ -78,6 +95,7 @@ const userProfileHandler = ({ { emailChangeAllowed, passwordLoginEnabled, + accountDeactivationAllowed, }, makeFragmentData( { @@ -99,6 +117,12 @@ const userProfileHandler = ({ }, PASSWORD_CHANGE_CONFIG_FRAGMENT, ), + makeFragmentData( + { + passwordLoginEnabled, + }, + ACCOUNT_DELETE_BUTTON_CONFIG_FRAGMENT, + ), ), }, }), @@ -153,6 +177,7 @@ export const MultipleEmails: Story = { passwordChangeAllowed: true, emailChangeAllowed: true, emailTotalCount: 3, + accountDeactivationAllowed: true, hasPassword: true, }), threeEmailsHandler, @@ -171,6 +196,7 @@ export const NoEmails: Story = { passwordChangeAllowed: true, emailChangeAllowed: false, emailTotalCount: 0, + accountDeactivationAllowed: true, hasPassword: true, }), ], @@ -188,6 +214,7 @@ export const MultipleEmailsNoChange: Story = { passwordChangeAllowed: true, emailChangeAllowed: false, emailTotalCount: 3, + accountDeactivationAllowed: true, hasPassword: true, }), threeEmailsHandler, @@ -206,6 +233,7 @@ export const NoEmailChange: Story = { passwordChangeAllowed: true, emailChangeAllowed: false, emailTotalCount: 1, + accountDeactivationAllowed: true, hasPassword: true, }), ], @@ -223,6 +251,7 @@ export const NoPasswordChange: Story = { passwordChangeAllowed: false, emailChangeAllowed: true, emailTotalCount: 1, + accountDeactivationAllowed: true, hasPassword: true, }), ], @@ -240,6 +269,7 @@ export const NoPasswordLogin: Story = { passwordChangeAllowed: false, emailChangeAllowed: true, emailTotalCount: 1, + accountDeactivationAllowed: true, hasPassword: true, }), ], @@ -247,8 +277,8 @@ export const NoPasswordLogin: Story = { }, }; -export const NoPasswordNoEmailChange: Story = { - name: "No password, no email change", +export const NoPasswordNoEmailChangeNoAccountDeactivation: Story = { + name: "No password, no email change, no account deactivation", parameters: { msw: { handlers: [ @@ -257,6 +287,7 @@ export const NoPasswordNoEmailChange: Story = { passwordChangeAllowed: false, emailChangeAllowed: false, emailTotalCount: 0, + accountDeactivationAllowed: false, hasPassword: false, }), ], @@ -264,6 +295,24 @@ export const NoPasswordNoEmailChange: Story = { }, }; +export const NoAccountDeactivation: Story = { + name: "No account deactivation", + parameters: { + msw: { + handlers: [ + userProfileHandler({ + passwordLoginEnabled: true, + passwordChangeAllowed: true, + emailChangeAllowed: true, + emailTotalCount: 1, + accountDeactivationAllowed: false, + hasPassword: true, + }), + ], + }, + }, +}; + export const EditProfile: Story = { play: async ({ canvasElement, globals }) => { const t = i18n.getFixedT(globals.locale); diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts index 55993aa11..c2753d186 100644 --- a/frontend/tests/mocks/handlers.ts +++ b/frontend/tests/mocks/handlers.ts @@ -4,6 +4,10 @@ // Please see LICENSE in the repository root for full details. import { HttpResponse } from "msw"; +import { + CONFIG_FRAGMENT as ACCOUNT_DELETE_BUTTON_CONFIG_FRAGMENT, + USER_FRAGMENT as ACCOUNT_DELETE_BUTTON_USER_FRAGMENT, +} from "../../src/components/AccountDeleteButton"; import { CONFIG_FRAGMENT as PASSWORD_CHANGE_CONFIG_FRAGMENT } from "../../src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview"; import { FRAGMENT as FOOTER_FRAGMENT } from "../../src/components/Footer/Footer"; import { FRAGMENT as USER_EMAIL_FRAGMENT } from "../../src/components/UserEmail/UserEmail"; @@ -113,6 +117,17 @@ export const handlers = [ }, USER_EMAIL_LIST_USER_FRAGMENT, ), + makeFragmentData( + { + hasPassword: true, + username: "alice", + matrix: { + displayName: "Alice", + mxid: "@alice:example.com", + }, + }, + ACCOUNT_DELETE_BUTTON_USER_FRAGMENT, + ), ), }, @@ -120,6 +135,7 @@ export const handlers = [ { emailChangeAllowed: true, passwordLoginEnabled: true, + accountDeactivationAllowed: true, }, makeFragmentData( { @@ -141,6 +157,12 @@ export const handlers = [ }, PASSWORD_CHANGE_CONFIG_FRAGMENT, ), + makeFragmentData( + { + passwordLoginEnabled: true, + }, + ACCOUNT_DELETE_BUTTON_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 f8f8bebea..40b463ea3 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 + + Sign out of account + +