From 8735bdb7f0239767d81865f5d617a1576d5e6c90 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 24 Apr 2025 16:44:33 +0200 Subject: [PATCH 01/14] matrix: allow setting a custom device display name --- crates/handlers/src/compat/login.rs | 4 +- .../src/graphql/mutations/oauth2_session.rs | 2 +- crates/handlers/src/oauth2/token.rs | 4 +- crates/matrix-synapse/src/lib.rs | 68 ++++++++++++++++-- crates/matrix/src/lib.rs | 70 +++++++++++++++++-- crates/matrix/src/mock.rs | 25 +++++-- crates/matrix/src/readonly.rs | 16 ++++- 7 files changed, 170 insertions(+), 19 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 78765b2cf..a06a36884 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -467,7 +467,7 @@ async fn token_login( }; let mxid = homeserver.mxid(&browser_session.user.username); homeserver - .create_device(&mxid, device.as_str()) + .create_device(&mxid, device.as_str(), None) .await .map_err(RouteError::ProvisionDeviceFailed)?; @@ -566,7 +566,7 @@ async fn user_password_login( Device::generate(&mut rng) }; homeserver - .create_device(&mxid, device.as_str()) + .create_device(&mxid, device.as_str(), None) .await .map_err(RouteError::ProvisionDeviceFailed)?; diff --git a/crates/handlers/src/graphql/mutations/oauth2_session.rs b/crates/handlers/src/graphql/mutations/oauth2_session.rs index 4278d20a2..058607536 100644 --- a/crates/handlers/src/graphql/mutations/oauth2_session.rs +++ b/crates/handlers/src/graphql/mutations/oauth2_session.rs @@ -168,7 +168,7 @@ impl OAuth2SessionMutations { for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str()) + .create_device(&mxid, device.as_str(), None) .await .context("Failed to provision device")?; } diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index a47207d9b..bbc088dfe 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -567,7 +567,7 @@ async fn authorization_code_grant( for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str()) + .create_device(&mxid, device.as_str(), None) .await .map_err(RouteError::ProvisionDeviceFailed)?; } @@ -943,7 +943,7 @@ async fn device_code_grant( for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str()) + .create_device(&mxid, device.as_str(), None) .await .map_err(RouteError::ProvisionDeviceFailed)?; } diff --git a/crates/matrix-synapse/src/lib.rs b/crates/matrix-synapse/src/lib.rs index 1b6627afe..b1857b7b8 100644 --- a/crates/matrix-synapse/src/lib.rs +++ b/crates/matrix-synapse/src/lib.rs @@ -133,6 +133,11 @@ struct SynapseDevice { dehydrated: Option, } +#[derive(Serialize)] +struct SynapseUpdateDeviceRequest<'a> { + display_name: Option<&'a str>, +} + #[derive(Serialize)] struct SynapseDeleteDevicesRequest { devices: Vec, @@ -312,11 +317,16 @@ impl HomeserverConnection for SynapseConnection { ), err(Debug), )] - async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { - let mxid = urlencoding::encode(mxid); + async fn create_device( + &self, + mxid: &str, + device_id: &str, + initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { + let encoded_mxid = urlencoding::encode(mxid); let response = self - .post(&format!("_synapse/admin/v2/users/{mxid}/devices")) + .post(&format!("_synapse/admin/v2/users/{encoded_mxid}/devices")) .json(&SynapseDevice { device_id: device_id.to_owned(), dehydrated: None, @@ -337,6 +347,56 @@ impl HomeserverConnection for SynapseConnection { ); } + // It's annoying, but the POST endpoint doesn't let us set the display name + // of the device, so we have to do it manually. + if let Some(display_name) = initial_display_name { + self.update_device_display_name(mxid, device_id, display_name) + .await?; + } + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.update_device_display_name", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.mxid = mxid, + matrix.device_id = device_id, + ), + err(Debug), + )] + async fn update_device_display_name( + &self, + mxid: &str, + device_id: &str, + display_name: &str, + ) -> Result<(), anyhow::Error> { + let device_id = urlencoding::encode(device_id); + let response = self + .put(&format!( + "_synapse/admin/v2/users/{mxid}/devices/{device_id}" + )) + .json(&SynapseUpdateDeviceRequest { + display_name: Some(display_name), + }) + .send_traced() + .await + .context("Failed to update device display name in Synapse")?; + + let response = response + .error_for_synapse_error() + .await + .context("Unexpected HTTP response while updating device display name in Synapse")?; + + if response.status() != StatusCode::OK { + bail!( + "Unexpected HTTP code while updating device display name in Synapse: {}", + response.status() + ); + } + Ok(()) } @@ -448,7 +508,7 @@ impl HomeserverConnection for SynapseConnection { // Then, create the devices that are missing. There is no batching API to do // this, so we do this sequentially, which is fine as the API is idempotent. for device_id in devices.difference(&existing_devices) { - self.create_device(mxid, device_id).await?; + self.create_device(mxid, device_id, None).await?; } Ok(()) diff --git a/crates/matrix/src/lib.rs b/crates/matrix/src/lib.rs index 59cdb4880..ae8a4e563 100644 --- a/crates/matrix/src/lib.rs +++ b/crates/matrix/src/lib.rs @@ -254,7 +254,31 @@ pub trait HomeserverConnection: Send + Sync { /// /// Returns an error if the homeserver is unreachable or the device could /// not be created. - async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error>; + async fn create_device( + &self, + mxid: &str, + device_id: &str, + initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error>; + + /// Update the display name of a device for a user on the homeserver. + /// + /// # Parameters + /// + /// * `mxid` - The Matrix ID of the user to update a device for. + /// * `device_id` - The device ID to update. + /// * `display_name` - The new display name to set + /// + /// # Errors + /// + /// Returns an error if the homeserver is unreachable or the device could + /// not be updated. + async fn update_device_display_name( + &self, + mxid: &str, + device_id: &str, + display_name: &str, + ) -> Result<(), anyhow::Error>; /// Delete a device for a user on the homeserver. /// @@ -364,8 +388,26 @@ impl HomeserverConnection for &T (**self).is_localpart_available(localpart).await } - async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { - (**self).create_device(mxid, device_id).await + async fn create_device( + &self, + mxid: &str, + device_id: &str, + initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { + (**self) + .create_device(mxid, device_id, initial_display_name) + .await + } + + async fn update_device_display_name( + &self, + mxid: &str, + device_id: &str, + display_name: &str, + ) -> Result<(), anyhow::Error> { + (**self) + .update_device_display_name(mxid, device_id, display_name) + .await } async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { @@ -420,8 +462,26 @@ impl HomeserverConnection for Arc { (**self).is_localpart_available(localpart).await } - async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { - (**self).create_device(mxid, device_id).await + async fn create_device( + &self, + mxid: &str, + device_id: &str, + initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { + (**self) + .create_device(mxid, device_id, initial_display_name) + .await + } + + async fn update_device_display_name( + &self, + mxid: &str, + device_id: &str, + display_name: &str, + ) -> Result<(), anyhow::Error> { + (**self) + .update_device_display_name(mxid, device_id, display_name) + .await } async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { diff --git a/crates/matrix/src/mock.rs b/crates/matrix/src/mock.rs index 22b9a43d5..7c7973ce0 100644 --- a/crates/matrix/src/mock.rs +++ b/crates/matrix/src/mock.rs @@ -107,13 +107,30 @@ impl crate::HomeserverConnection for HomeserverConnection { Ok(!users.contains_key(&mxid)) } - async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { + async fn create_device( + &self, + mxid: &str, + device_id: &str, + _initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { let mut users = self.users.write().await; let user = users.get_mut(mxid).context("User not found")?; user.devices.insert(device_id.to_owned()); Ok(()) } + async fn update_device_display_name( + &self, + mxid: &str, + device_id: &str, + _display_name: &str, + ) -> Result<(), anyhow::Error> { + let mut users = self.users.write().await; + let user = users.get_mut(mxid).context("User not found")?; + user.devices.get(device_id).context("Device not found")?; + Ok(()) + } + async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> { let mut users = self.users.write().await; let user = users.get_mut(mxid).context("User not found")?; @@ -191,7 +208,7 @@ mod tests { assert_eq!(conn.mxid("test"), mxid); assert!(conn.query_user(mxid).await.is_err()); - assert!(conn.create_device(mxid, device).await.is_err()); + assert!(conn.create_device(mxid, device, None).await.is_err()); assert!(conn.delete_device(mxid, device).await.is_err()); let request = ProvisionRequest::new("@test:example.org", "test") @@ -222,9 +239,9 @@ mod tests { assert!(conn.delete_device(mxid, device).await.is_ok()); // Create the device - assert!(conn.create_device(mxid, device).await.is_ok()); + assert!(conn.create_device(mxid, device, None).await.is_ok()); // Create the same device again - assert!(conn.create_device(mxid, device).await.is_ok()); + assert!(conn.create_device(mxid, device, None).await.is_ok()); // XXX: there is no API to query devices yet in the trait // Delete the device diff --git a/crates/matrix/src/readonly.rs b/crates/matrix/src/readonly.rs index b51040080..530c3cd89 100644 --- a/crates/matrix/src/readonly.rs +++ b/crates/matrix/src/readonly.rs @@ -40,10 +40,24 @@ impl HomeserverConnection for ReadOnlyHomeserverConnect self.inner.is_localpart_available(localpart).await } - async fn create_device(&self, _mxid: &str, _device_id: &str) -> Result<(), anyhow::Error> { + async fn create_device( + &self, + _mxid: &str, + _device_id: &str, + _initial_display_name: Option<&str>, + ) -> Result<(), anyhow::Error> { anyhow::bail!("Device creation is not supported in read-only mode"); } + async fn update_device_display_name( + &self, + _mxid: &str, + _device_id: &str, + _display_name: &str, + ) -> Result<(), anyhow::Error> { + anyhow::bail!("Device display name update is not supported in read-only mode"); + } + async fn delete_device(&self, _mxid: &str, _device_id: &str) -> Result<(), anyhow::Error> { anyhow::bail!("Device deletion is not supported in read-only mode"); } From 60a0285d367f71265257df7d35e77c8d17773bce Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 24 Apr 2025 16:46:37 +0200 Subject: [PATCH 02/14] storage: allow setting the human_name when creating compat sessions --- crates/cli/src/commands/manage.rs | 2 +- crates/handlers/src/admin/v1/compat_sessions/get.rs | 2 +- crates/handlers/src/admin/v1/compat_sessions/list.rs | 4 ++-- crates/handlers/src/compat/login.rs | 3 ++- ...48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c.json} | 7 ++++--- crates/storage-pg/src/app_session.rs | 2 +- crates/storage-pg/src/compat/mod.rs | 9 +++++---- crates/storage-pg/src/compat/session.rs | 9 ++++++--- crates/storage/src/compat/session.rs | 5 +++++ 9 files changed, 27 insertions(+), 16 deletions(-) rename crates/storage-pg/.sqlx/{query-cf1273b8aaaccedeb212a971d5e8e0dd23bfddab0ec08ee192783e103a1c4766.json => query-e99ab37ab3e03ad9c48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c.json} (56%) diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index ba4541e2f..390897ce7 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -305,7 +305,7 @@ impl Options { let compat_session = repo .compat_session() - .add(&mut rng, &clock, &user, device, None, admin) + .add(&mut rng, &clock, &user, device, None, admin, None) .await?; let token = TokenType::CompatAccessToken.generate(&mut rng); diff --git a/crates/handlers/src/admin/v1/compat_sessions/get.rs b/crates/handlers/src/admin/v1/compat_sessions/get.rs index f39fc79da..d27146d59 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/get.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/get.rs @@ -107,7 +107,7 @@ mod tests { let device = Device::generate(&mut rng); let session = repo .compat_session() - .add(&mut rng, &state.clock, &user, device, None, false) + .add(&mut rng, &state.clock, &user, device, None, false, None) .await .unwrap(); repo.save().await.unwrap(); diff --git a/crates/handlers/src/admin/v1/compat_sessions/list.rs b/crates/handlers/src/admin/v1/compat_sessions/list.rs index a882f6d56..5a47b0571 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/list.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/list.rs @@ -251,7 +251,7 @@ mod tests { let device = Device::generate(&mut rng); repo.compat_session() - .add(&mut rng, &state.clock, &alice, device, None, false) + .add(&mut rng, &state.clock, &alice, device, None, false, None) .await .unwrap(); let device = Device::generate(&mut rng); @@ -260,7 +260,7 @@ mod tests { let session = repo .compat_session() - .add(&mut rng, &state.clock, &bob, device, None, false) + .add(&mut rng, &state.clock, &bob, device, None, false, None) .await .unwrap(); state.clock.advance(Duration::minutes(1)); diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index a06a36884..a1f7873b6 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -484,6 +484,7 @@ async fn token_login( device, Some(&browser_session), false, + None, ) .await?; @@ -576,7 +577,7 @@ async fn user_password_login( let session = repo .compat_session() - .add(&mut rng, clock, &user, device, None, false) + .add(&mut rng, clock, &user, device, None, false, None) .await?; Ok((session, user)) diff --git a/crates/storage-pg/.sqlx/query-cf1273b8aaaccedeb212a971d5e8e0dd23bfddab0ec08ee192783e103a1c4766.json b/crates/storage-pg/.sqlx/query-e99ab37ab3e03ad9c48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c.json similarity index 56% rename from crates/storage-pg/.sqlx/query-cf1273b8aaaccedeb212a971d5e8e0dd23bfddab0ec08ee192783e103a1c4766.json rename to crates/storage-pg/.sqlx/query-e99ab37ab3e03ad9c48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c.json index 35f6b5973..04ad6dd39 100644 --- a/crates/storage-pg/.sqlx/query-cf1273b8aaaccedeb212a971d5e8e0dd23bfddab0ec08ee192783e103a1c4766.json +++ b/crates/storage-pg/.sqlx/query-e99ab37ab3e03ad9c48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO compat_sessions\n (compat_session_id, user_id, device_id,\n user_session_id, created_at, is_synapse_admin)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "query": "\n INSERT INTO compat_sessions\n (compat_session_id, user_id, device_id,\n user_session_id, created_at, is_synapse_admin,\n human_name)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", "describe": { "columns": [], "parameters": { @@ -10,10 +10,11 @@ "Text", "Uuid", "Timestamptz", - "Bool" + "Bool", + "Text" ] }, "nullable": [] }, - "hash": "cf1273b8aaaccedeb212a971d5e8e0dd23bfddab0ec08ee192783e103a1c4766" + "hash": "e99ab37ab3e03ad9c48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c" } diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index 2c747c8ca..1d759c2ba 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -571,7 +571,7 @@ mod tests { let device = Device::generate(&mut rng); let compat_session = repo .compat_session() - .add(&mut rng, &clock, &user, device.clone(), None, false) + .add(&mut rng, &clock, &user, device.clone(), None, false, None) .await .unwrap(); diff --git a/crates/storage-pg/src/compat/mod.rs b/crates/storage-pg/src/compat/mod.rs index 8ceb089b7..60332fd50 100644 --- a/crates/storage-pg/src/compat/mod.rs +++ b/crates/storage-pg/src/compat/mod.rs @@ -79,7 +79,7 @@ mod tests { let device_str = device.as_str().to_owned(); let session = repo .compat_session() - .add(&mut rng, &clock, &user, device.clone(), None, false) + .add(&mut rng, &clock, &user, device.clone(), None, false, None) .await .unwrap(); assert_eq!(session.user_id, user.id); @@ -227,6 +227,7 @@ mod tests { device, Some(&browser_session), false, + None, ) .await .unwrap(); @@ -331,7 +332,7 @@ mod tests { let device = Device::generate(&mut rng); let session = repo .compat_session() - .add(&mut rng, &clock, &user, device, None, false) + .add(&mut rng, &clock, &user, device, None, false, None) .await .unwrap(); @@ -452,7 +453,7 @@ mod tests { let device = Device::generate(&mut rng); let session = repo .compat_session() - .add(&mut rng, &clock, &user, device, None, false) + .add(&mut rng, &clock, &user, device, None, false, None) .await .unwrap(); @@ -618,7 +619,7 @@ mod tests { let device = Device::generate(&mut rng); let compat_session = repo .compat_session() - .add(&mut rng, &clock, &user, device, None, false) + .add(&mut rng, &clock, &user, device, None, false, None) .await .unwrap(); diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index c844be238..a38b11689 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -305,6 +305,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { device: Device, browser_session: Option<&BrowserSession>, is_synapse_admin: bool, + human_name: Option, ) -> Result { let created_at = clock.now(); let id = Ulid::from_datetime_with_source(created_at.into(), rng); @@ -314,8 +315,9 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { r#" INSERT INTO compat_sessions (compat_session_id, user_id, device_id, - user_session_id, created_at, is_synapse_admin) - VALUES ($1, $2, $3, $4, $5, $6) + user_session_id, created_at, is_synapse_admin, + human_name) + VALUES ($1, $2, $3, $4, $5, $6, $7) "#, Uuid::from(id), Uuid::from(user.id), @@ -323,6 +325,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { browser_session.map(|s| Uuid::from(s.id)), created_at, is_synapse_admin, + human_name.as_deref(), ) .traced() .execute(&mut *self.conn) @@ -333,7 +336,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { state: CompatSessionState::default(), user_id: user.id, device: Some(device), - human_name: None, + human_name, user_session_id: browser_session.map(|s| s.id), created_at, is_synapse_admin, diff --git a/crates/storage/src/compat/session.rs b/crates/storage/src/compat/session.rs index 757f5269b..f8747d754 100644 --- a/crates/storage/src/compat/session.rs +++ b/crates/storage/src/compat/session.rs @@ -215,10 +215,13 @@ pub trait CompatSessionRepository: Send + Sync { /// * `device`: The device ID of this session /// * `browser_session`: The browser session which created this session /// * `is_synapse_admin`: Whether the session is a synapse admin session + /// * `human_name`: The human-readable name of the session provided by the + /// client or the user /// /// # Errors /// /// Returns [`Self::Error`] if the underlying repository fails + #[expect(clippy::too_many_arguments)] async fn add( &mut self, rng: &mut (dyn RngCore + Send), @@ -227,6 +230,7 @@ pub trait CompatSessionRepository: Send + Sync { device: Device, browser_session: Option<&BrowserSession>, is_synapse_admin: bool, + human_name: Option, ) -> Result; /// End a compat session @@ -337,6 +341,7 @@ repository_impl!(CompatSessionRepository: device: Device, browser_session: Option<&BrowserSession>, is_synapse_admin: bool, + human_name: Option, ) -> Result; async fn finish( From 4341bff2352611e10ee201090bf72574b2e5b36a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 24 Apr 2025 16:47:32 +0200 Subject: [PATCH 03/14] frontend: expose the compat session humanName --- .../src/graphql/model/compat_sessions.rs | 5 +++++ frontend/schema.graphql | 4 ++++ frontend/src/components/CompatSession.tsx | 9 ++++++--- .../SessionDetail/CompatSessionDetail.tsx | 17 ++++++++++------- frontend/src/gql/gql.ts | 12 ++++++------ frontend/src/gql/graphql.ts | 10 ++++++++-- 6 files changed, 39 insertions(+), 18 deletions(-) diff --git a/crates/handlers/src/graphql/model/compat_sessions.rs b/crates/handlers/src/graphql/model/compat_sessions.rs index 77ed7e6cc..90adb61fe 100644 --- a/crates/handlers/src/graphql/model/compat_sessions.rs +++ b/crates/handlers/src/graphql/model/compat_sessions.rs @@ -165,6 +165,11 @@ impl CompatSession { pub async fn last_active_at(&self) -> Option> { self.session.last_active_at } + + /// A human-provided name for the session. + pub async fn human_name(&self) -> Option<&str> { + self.session.human_name.as_deref() + } } /// A compat SSO login represents a login done through the legacy Matrix login diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 4fdc85332..3f5dbc8d3 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -370,6 +370,10 @@ type CompatSession implements Node & CreationEvent { The last time the session was active. """ lastActiveAt: DateTime + """ + A human-provided name for the session. + """ + humanName: String } type CompatSessionConnection { diff --git a/frontend/src/components/CompatSession.tsx b/frontend/src/components/CompatSession.tsx index 2ea3fdd60..2770993ad 100644 --- a/frontend/src/components/CompatSession.tsx +++ b/frontend/src/components/CompatSession.tsx @@ -22,6 +22,7 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + humanName ...EndCompatSessionButton_session userAgent { name @@ -42,9 +43,11 @@ const CompatSession: React.FC<{ const { t } = useTranslation(); const data = useFragment(FRAGMENT, session); - const clientName = data.ssoLogin?.redirectUri - ? simplifyUrl(data.ssoLogin.redirectUri) - : undefined; + const clientName = + data.humanName ?? + (data.ssoLogin?.redirectUri + ? simplifyUrl(data.ssoLogin.redirectUri) + : undefined); const deviceType = data.userAgent?.deviceType ?? "UNKNOWN"; diff --git a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx index 144e7ef37..47ab93d8d 100644 --- a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx +++ b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx @@ -23,6 +23,7 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + humanName ...EndCompatSessionButton_session @@ -62,11 +63,11 @@ const CompatSessionDetail: React.FC = ({ session }) => { ? simplifyUrl(data.ssoLogin.redirectUri) : data.deviceId || data.id; + const sessionName = data.humanName ?? `${clientName}: ${deviceName}`; + return (
- - {clientName}: {deviceName} - + {sessionName} {t("frontend.session.title")} @@ -141,10 +142,12 @@ const CompatSessionDetail: React.FC = ({ session }) => { {deviceName} - - {t("frontend.session.uri_label")} - {data.ssoLogin?.redirectUri} - + {data.ssoLogin && ( + + {t("frontend.session.uri_label")} + {data.ssoLogin?.redirectUri} + + )} diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index aeb68252f..0bc5e469f 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -21,7 +21,7 @@ type Documents = { "\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, - "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.CompatSession_SessionFragmentDoc, + "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.CompatSession_SessionFragmentDoc, "\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": typeof types.Footer_SiteConfigFragmentDoc, "\n query Footer {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": typeof types.FooterDocument, "\n fragment OAuth2Session_session 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 deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": typeof types.OAuth2Session_SessionFragmentDoc, @@ -33,7 +33,7 @@ type Documents = { "\n fragment EndOAuth2SessionButton_session on Oauth2Session {\n id\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": typeof types.EndOAuth2SessionButton_SessionFragmentDoc, "\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n }\n }\n }\n": typeof types.EndOAuth2SessionDocument, "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n": typeof types.BrowserSession_DetailFragmentDoc, - "\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 CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\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 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, @@ -75,7 +75,7 @@ const documents: Documents = { "\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, - "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_SessionFragmentDoc, + "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_SessionFragmentDoc, "\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": types.Footer_SiteConfigFragmentDoc, "\n query Footer {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": types.FooterDocument, "\n fragment OAuth2Session_session 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 deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc, @@ -87,7 +87,7 @@ const documents: Documents = { "\n fragment EndOAuth2SessionButton_session on Oauth2Session {\n id\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.EndOAuth2SessionButton_SessionFragmentDoc, "\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n }\n }\n }\n": types.EndOAuth2SessionDocument, "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n": types.BrowserSession_DetailFragmentDoc, - "\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 CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\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 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, @@ -150,7 +150,7 @@ export function graphql(source: "\n fragment OAuth2Client_detail on Oauth2Clien /** * 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 CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n"): typeof import('./graphql').CompatSession_SessionFragmentDoc; +export function graphql(source: "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n"): typeof import('./graphql').CompatSession_SessionFragmentDoc; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -198,7 +198,7 @@ export function graphql(source: "\n fragment BrowserSession_detail on BrowserSe /** * 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 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 import('./graphql').CompatSession_DetailFragmentDoc; +export function graphql(source: "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\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 import('./graphql').CompatSession_DetailFragmentDoc; /** * 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 6bdb8d33f..5cd9d7c8a 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -238,6 +238,8 @@ export type CompatSession = CreationEvent & Node & { deviceId?: Maybe; /** When the session ended. */ finishedAt?: Maybe; + /** A human-provided name for the session. */ + humanName?: Maybe; /** ID of the object. */ id: Scalars['ID']['output']; /** The last time the session was active. */ @@ -1650,7 +1652,7 @@ export type BrowserSession_SessionFragment = ( export type OAuth2Client_DetailFragment = { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, clientUri?: string | null, logoUri?: string | null, tosUri?: string | null, policyUri?: string | null, redirectUris: Array } & { ' $fragmentName'?: 'OAuth2Client_DetailFragment' }; export type CompatSession_SessionFragment = ( - { __typename?: 'CompatSession', id: string, createdAt: string, deviceId?: string | null, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null, deviceType: DeviceType } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null } + { __typename?: 'CompatSession', id: string, createdAt: string, deviceId?: string | null, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, humanName?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null, deviceType: DeviceType } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null } & { ' $fragmentRefs'?: { 'EndCompatSessionButton_SessionFragment': EndCompatSessionButton_SessionFragment } } ) & { ' $fragmentName'?: 'CompatSession_SessionFragment' }; @@ -1704,7 +1706,7 @@ export type BrowserSession_DetailFragment = ( ) & { ' $fragmentName'?: 'BrowserSession_DetailFragment' }; export type CompatSession_DetailFragment = ( - { __typename?: 'CompatSession', id: string, createdAt: string, deviceId?: string | null, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null } + { __typename?: 'CompatSession', id: string, createdAt: string, deviceId?: string | null, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, humanName?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null } & { ' $fragmentRefs'?: { 'EndCompatSessionButton_SessionFragment': EndCompatSessionButton_SessionFragment } } ) & { ' $fragmentName'?: 'CompatSession_DetailFragment' }; @@ -2056,6 +2058,7 @@ export const CompatSession_SessionFragmentDoc = new TypedDocumentString(` finishedAt lastActiveIp lastActiveAt + humanName ...EndCompatSessionButton_session userAgent { name @@ -2183,6 +2186,7 @@ export const CompatSession_DetailFragmentDoc = new TypedDocumentString(` finishedAt lastActiveIp lastActiveAt + humanName ...EndCompatSessionButton_session userAgent { name @@ -2587,6 +2591,7 @@ export const AppSessionsListDocument = new TypedDocumentString(` finishedAt lastActiveIp lastActiveAt + humanName ...EndCompatSessionButton_session userAgent { name @@ -2880,6 +2885,7 @@ fragment CompatSession_detail on CompatSession { finishedAt lastActiveIp lastActiveAt + humanName ...EndCompatSessionButton_session userAgent { name From d550fce6982bf3ecb8c2c08736e7f6d5a980233a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 24 Apr 2025 16:49:56 +0200 Subject: [PATCH 04/14] compat: allow setting an initial_device_display_name on login --- crates/handlers/src/compat/login.rs | 43 ++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index a1f7873b6..76148df75 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -116,6 +116,9 @@ pub struct RequestBody { /// this is not specified. #[serde(default, skip_serializing_if = "Option::is_none")] device_id: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + initial_device_display_name: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -309,18 +312,20 @@ pub(crate) async fn post( user, password, input.device_id, // TODO check for validity + input.initial_device_display_name, ) .await? } (_, Credentials::Token { token }) => { token_login( - &mut repo, + &mut rng, &clock, + &mut repo, + &homeserver, &token, input.device_id, - &homeserver, - &mut rng, + input.initial_device_display_name, ) .await? } @@ -387,12 +392,13 @@ pub(crate) async fn post( } async fn token_login( - repo: &mut BoxRepository, + rng: &mut (dyn RngCore + Send), clock: &dyn Clock, + repo: &mut BoxRepository, + homeserver: &dyn HomeserverConnection, token: &str, requested_device_id: Option, - homeserver: &dyn HomeserverConnection, - rng: &mut (dyn RngCore + Send), + initial_device_display_name: Option, ) -> Result<(CompatSession, User), RouteError> { let login = repo .compat_sso_login() @@ -467,7 +473,11 @@ async fn token_login( }; let mxid = homeserver.mxid(&browser_session.user.username); homeserver - .create_device(&mxid, device.as_str(), None) + .create_device( + &mxid, + device.as_str(), + initial_device_display_name.as_deref(), + ) .await .map_err(RouteError::ProvisionDeviceFailed)?; @@ -484,7 +494,7 @@ async fn token_login( device, Some(&browser_session), false, - None, + initial_device_display_name, ) .await?; @@ -506,6 +516,7 @@ async fn user_password_login( username: String, password: String, requested_device_id: Option, + initial_device_display_name: Option, ) -> Result<(CompatSession, User), RouteError> { // Try getting the localpart out of the MXID let username = homeserver.localpart(&username).unwrap_or(&username); @@ -567,7 +578,11 @@ async fn user_password_login( Device::generate(&mut rng) }; homeserver - .create_device(&mxid, device.as_str(), None) + .create_device( + &mxid, + device.as_str(), + initial_device_display_name.as_deref(), + ) .await .map_err(RouteError::ProvisionDeviceFailed)?; @@ -577,7 +592,15 @@ async fn user_password_login( let session = repo .compat_session() - .add(&mut rng, clock, &user, device, None, false, None) + .add( + &mut rng, + clock, + &user, + device, + None, + false, + initial_device_display_name, + ) .await?; Ok((session, user)) From b708c403e30088baae2aa9dbf8cafc05d12119cb Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 24 Apr 2025 17:19:51 +0200 Subject: [PATCH 05/14] Save the locale detected when starting an authorization grant --- crates/data-model/src/oauth2/authorization_grant.rs | 2 ++ crates/handlers/src/oauth2/authorization/mod.rs | 1 + crates/handlers/src/oauth2/token.rs | 2 ++ ...67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567.json} | 5 +++-- ...31ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035.json} | 10 ++++++++-- ...49898125979f3c78c2caca52cb4b8dc9880e669a1f23e.json} | 10 ++++++++-- .../migrations/20250424150930_oauth2_grants_locale.sql | 8 ++++++++ crates/storage-pg/src/oauth2/authorization_grant.rs | 10 +++++++++- crates/storage-pg/src/oauth2/mod.rs | 1 + crates/storage/src/oauth2/authorization_grant.rs | 4 ++++ 10 files changed, 46 insertions(+), 7 deletions(-) rename crates/storage-pg/.sqlx/{query-96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28.json => query-7a0641df5058927c5cd67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567.json} (76%) rename crates/storage-pg/.sqlx/{query-890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251.json => query-8ef27901b96b73826a431ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035.json} (87%) rename crates/storage-pg/.sqlx/{query-bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4.json => query-c960f4f5571ee68816c49898125979f3c78c2caca52cb4b8dc9880e669a1f23e.json} (86%) create mode 100644 crates/storage-pg/migrations/20250424150930_oauth2_grants_locale.sql diff --git a/crates/data-model/src/oauth2/authorization_grant.rs b/crates/data-model/src/oauth2/authorization_grant.rs index 170e476d0..1d71f0170 100644 --- a/crates/data-model/src/oauth2/authorization_grant.rs +++ b/crates/data-model/src/oauth2/authorization_grant.rs @@ -160,6 +160,7 @@ pub struct AuthorizationGrant { pub response_type_id_token: bool, pub created_at: DateTime, pub login_hint: Option, + pub locale: Option, } impl std::ops::Deref for AuthorizationGrant { @@ -263,6 +264,7 @@ impl AuthorizationGrant { response_type_id_token: false, created_at: now, login_hint: Some(String::from("mxid:@example-user:example.com")), + locale: Some(String::from("fr")), } } } diff --git a/crates/handlers/src/oauth2/authorization/mod.rs b/crates/handlers/src/oauth2/authorization/mod.rs index cde23f636..c3b080eae 100644 --- a/crates/handlers/src/oauth2/authorization/mod.rs +++ b/crates/handlers/src/oauth2/authorization/mod.rs @@ -274,6 +274,7 @@ pub(crate) async fn get( response_mode, response_type.has_id_token(), params.auth.login_hint, + Some(locale.to_string()), ) .await?; let continue_grant = PostAuthAction::continue_grant(grant.id); diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index bbc088dfe..08cfcb1d4 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -1042,6 +1042,7 @@ mod tests { ResponseMode::Query, false, None, + None, ) .await .unwrap(); @@ -1141,6 +1142,7 @@ mod tests { ResponseMode::Query, false, None, + None, ) .await .unwrap(); diff --git a/crates/storage-pg/.sqlx/query-96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28.json b/crates/storage-pg/.sqlx/query-7a0641df5058927c5cd67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567.json similarity index 76% rename from crates/storage-pg/.sqlx/query-96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28.json rename to crates/storage-pg/.sqlx/query-7a0641df5058927c5cd67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567.json index 2f372898b..22c3bc0eb 100644 --- a/crates/storage-pg/.sqlx/query-96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28.json +++ b/crates/storage-pg/.sqlx/query-7a0641df5058927c5cd67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO oauth2_authorization_grants (\n oauth2_authorization_grant_id,\n oauth2_client_id,\n redirect_uri,\n scope,\n state,\n nonce,\n response_mode,\n code_challenge,\n code_challenge_method,\n response_type_code,\n response_type_id_token,\n authorization_code,\n login_hint,\n created_at\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n ", + "query": "\n INSERT INTO oauth2_authorization_grants (\n oauth2_authorization_grant_id,\n oauth2_client_id,\n redirect_uri,\n scope,\n state,\n nonce,\n response_mode,\n code_challenge,\n code_challenge_method,\n response_type_code,\n response_type_id_token,\n authorization_code,\n login_hint,\n locale,\n created_at\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ", "describe": { "columns": [], "parameters": { @@ -18,10 +18,11 @@ "Bool", "Text", "Text", + "Text", "Timestamptz" ] }, "nullable": [] }, - "hash": "96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28" + "hash": "7a0641df5058927c5cd67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567" } diff --git a/crates/storage-pg/.sqlx/query-890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251.json b/crates/storage-pg/.sqlx/query-8ef27901b96b73826a431ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035.json similarity index 87% rename from crates/storage-pg/.sqlx/query-890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251.json rename to crates/storage-pg/.sqlx/query-8ef27901b96b73826a431ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035.json index d8fd25487..0a5d83f0a 100644 --- a/crates/storage-pg/.sqlx/query-890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251.json +++ b/crates/storage-pg/.sqlx/query-8ef27901b96b73826a431ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE authorization_code = $1\n ", + "query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , locale\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE authorization_code = $1\n ", "describe": { "columns": [ { @@ -90,6 +90,11 @@ }, { "ordinal": 17, + "name": "locale", + "type_info": "Text" + }, + { + "ordinal": 18, "name": "oauth2_session_id", "type_info": "Uuid" } @@ -117,8 +122,9 @@ true, true, true, + true, true ] }, - "hash": "890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251" + "hash": "8ef27901b96b73826a431ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035" } diff --git a/crates/storage-pg/.sqlx/query-bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4.json b/crates/storage-pg/.sqlx/query-c960f4f5571ee68816c49898125979f3c78c2caca52cb4b8dc9880e669a1f23e.json similarity index 86% rename from crates/storage-pg/.sqlx/query-bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4.json rename to crates/storage-pg/.sqlx/query-c960f4f5571ee68816c49898125979f3c78c2caca52cb4b8dc9880e669a1f23e.json index 7a52e4781..20cd2c704 100644 --- a/crates/storage-pg/.sqlx/query-bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4.json +++ b/crates/storage-pg/.sqlx/query-c960f4f5571ee68816c49898125979f3c78c2caca52cb4b8dc9880e669a1f23e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE oauth2_authorization_grant_id = $1\n ", + "query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , locale\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE oauth2_authorization_grant_id = $1\n ", "describe": { "columns": [ { @@ -90,6 +90,11 @@ }, { "ordinal": 17, + "name": "locale", + "type_info": "Text" + }, + { + "ordinal": 18, "name": "oauth2_session_id", "type_info": "Uuid" } @@ -117,8 +122,9 @@ true, true, true, + true, true ] }, - "hash": "bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4" + "hash": "c960f4f5571ee68816c49898125979f3c78c2caca52cb4b8dc9880e669a1f23e" } diff --git a/crates/storage-pg/migrations/20250424150930_oauth2_grants_locale.sql b/crates/storage-pg/migrations/20250424150930_oauth2_grants_locale.sql new file mode 100644 index 000000000..699f70cf1 --- /dev/null +++ b/crates/storage-pg/migrations/20250424150930_oauth2_grants_locale.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Track the locale of the user which asked for the authorization grant +ALTER TABLE oauth2_authorization_grants + ADD COLUMN locale TEXT; diff --git a/crates/storage-pg/src/oauth2/authorization_grant.rs b/crates/storage-pg/src/oauth2/authorization_grant.rs index d619573e7..59c5c2338 100644 --- a/crates/storage-pg/src/oauth2/authorization_grant.rs +++ b/crates/storage-pg/src/oauth2/authorization_grant.rs @@ -52,6 +52,7 @@ struct GrantLookup { code_challenge: Option, code_challenge_method: Option, login_hint: Option, + locale: Option, oauth2_client_id: Uuid, oauth2_session_id: Option, } @@ -162,6 +163,7 @@ impl TryFrom for AuthorizationGrant { created_at: value.created_at, response_type_id_token: value.response_type_id_token, login_hint: value.login_hint, + locale: value.locale, }) } } @@ -194,6 +196,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository response_mode: ResponseMode, response_type_id_token: bool, login_hint: Option, + locale: Option, ) -> Result { let code_challenge = code .as_ref() @@ -225,10 +228,11 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository response_type_id_token, authorization_code, login_hint, + locale, created_at ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) "#, Uuid::from(id), Uuid::from(client.id), @@ -243,6 +247,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository response_type_id_token, code_str, login_hint, + locale, created_at, ) .traced() @@ -262,6 +267,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository created_at, response_type_id_token, login_hint, + locale, }) } @@ -295,6 +301,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository , code_challenge , code_challenge_method , login_hint + , locale , oauth2_session_id FROM oauth2_authorization_grants @@ -344,6 +351,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository , code_challenge , code_challenge_method , login_hint + , locale , oauth2_session_id FROM oauth2_authorization_grants diff --git a/crates/storage-pg/src/oauth2/mod.rs b/crates/storage-pg/src/oauth2/mod.rs index d5e7f7694..3f70fd5cc 100644 --- a/crates/storage-pg/src/oauth2/mod.rs +++ b/crates/storage-pg/src/oauth2/mod.rs @@ -138,6 +138,7 @@ mod tests { ResponseMode::Query, true, None, + None, ) .await .unwrap(); diff --git a/crates/storage/src/oauth2/authorization_grant.rs b/crates/storage/src/oauth2/authorization_grant.rs index 7724ace87..cb4802a92 100644 --- a/crates/storage/src/oauth2/authorization_grant.rs +++ b/crates/storage/src/oauth2/authorization_grant.rs @@ -39,6 +39,8 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync { /// * `response_type_id_token`: Whether the `id_token` `response_type` was /// requested /// * `login_hint`: The login_hint the client sent, if set + /// * `locale`: The locale the detected when the user asked for the + /// authorization grant /// /// # Errors /// @@ -57,6 +59,7 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync { response_mode: ResponseMode, response_type_id_token: bool, login_hint: Option, + locale: Option, ) -> Result; /// Lookup an authorization grant by its ID @@ -140,6 +143,7 @@ repository_impl!(OAuth2AuthorizationGrantRepository: response_mode: ResponseMode, response_type_id_token: bool, login_hint: Option, + locale: Option, ) -> Result; async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; From fc94c751bcfa6604ffc4c11d657cca51cb1c662e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Apr 2025 09:44:00 +0200 Subject: [PATCH 06/14] templates: introduce a `parse_user_agent` filter and use it in the device consent page --- crates/templates/src/functions.rs | 7 +++++++ templates/pages/device_consent.html | 31 +++++++++++++++-------------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index edda9783a..3229cde28 100644 --- a/crates/templates/src/functions.rs +++ b/crates/templates/src/functions.rs @@ -40,6 +40,7 @@ pub fn register( env.add_filter("to_params", filter_to_params); env.add_filter("simplify_url", filter_simplify_url); env.add_filter("add_slashes", filter_add_slashes); + env.add_filter("parse_user_agent", filter_parse_user_agent); env.add_function("add_params_to_url", function_add_params_to_url); env.add_function("counter", || Ok(Value::from_object(Counter::default()))); env.add_global( @@ -133,6 +134,12 @@ fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result Value { + let user_agent = mas_data_model::UserAgent::parse(user_agent); + Value::from_serialize(user_agent) +} + enum ParamsWhere { Fragment, Query, diff --git a/templates/pages/device_consent.html b/templates/pages/device_consent.html index e8abbdb15..abd853976 100644 --- a/templates/pages/device_consent.html +++ b/templates/pages/device_consent.html @@ -12,6 +12,7 @@ {% block content %} {% set client_name = client.client_name or client.client_id %} + {% set user_agent = grant.user_agent | parse_user_agent() %} {% if grant.state == "pending" %}
@@ -27,13 +28,13 @@

{{ _("mas.consent.heading") }}

-
+
- {% if grant.user_agent.device_type == "mobile" %} + {% if user_agent.device_type == "mobile" %} {{ icon.mobile() }} - {% elif grant.user_agent.device_type == "tablet" %} + {% elif user_agent.device_type == "tablet" %} {{ icon.web_browser() }} - {% elif grant.user_agent.device_type == "pc" %} + {% elif user_agent.device_type == "pc" %} {{ icon.computer() }} {% else %} {{ icon.unknown_solid() }} @@ -41,31 +42,31 @@

{{ _("mas.consent.heading") }}

- {% if grant.user_agent.model %} -
{{ grant.user_agent.model }}
+ {% if user_agent.model %} +
{{ user_agent.model }}
{% endif %} - {% if grant.user_agent.os %} + {% if user_agent.os %}
- {{ grant.user_agent.os }} - {% if grant.user_agent.os_version %} - {{ grant.user_agent.os_version }} + {{ user_agent.os }} + {% if user_agent.os_version %} + {{ user_agent.os_version }} {% endif %}
{% endif %} {# If we haven't detected a model, it's probably a browser, so show the name #} - {% if not grant.user_agent.model and grant.user_agent.name %} + {% if not user_agent.model and user_agent.name %}
- {{ grant.user_agent.name }} - {% if grant.user_agent.version %} - {{ grant.user_agent.version }} + {{ user_agent.name }} + {% if user_agent.version %} + {{ user_agent.version }} {% endif %}
{% endif %} {# If we couldn't detect anything, show a generic "Device" #} - {% if not grant.user_agent.model and not grant.user_agent.name and not grant.user_agent.os %} + {% if not user_agent.model and not user_agent.name and not user_agent.os %}
{{ _("mas.device_card.generic_device") }}
{% endif %}
From 7c0eeec347e3938f1b3976ff769f35a1343e5f3e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Apr 2025 12:54:11 +0200 Subject: [PATCH 07/14] Generate a device name based on the client name and user agent --- crates/handlers/src/lib.rs | 1 + crates/handlers/src/oauth2/token.rs | 14 +++++++- crates/i18n/src/lib.rs | 2 +- crates/templates/src/context.rs | 33 +++++++++++++++++++ crates/templates/src/lib.rs | 18 +++++++---- templates/device_name.txt | 28 ++++++++++++++++ translations/en.json | 50 +++++++++++++++++++---------- 7 files changed, 120 insertions(+), 26 deletions(-) create mode 100644 templates/device_name.txt diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 4d610482f..6032b7e07 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -203,6 +203,7 @@ where Encrypter: FromRef, reqwest::Client: FromRef, SiteConfig: FromRef, + Templates: FromRef, Arc: FromRef, BoxClock: FromRequestParts, BoxRng: FromRequestParts, diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 08cfcb1d4..3c8c9db20 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -18,6 +18,7 @@ use mas_axum_utils::{ use mas_data_model::{ AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, SiteConfig, TokenType, }; +use mas_i18n::DataLocale; use mas_keystore::{Encrypter, Keystore}; use mas_matrix::HomeserverConnection; use mas_oidc_client::types::scope::ScopeToken; @@ -31,6 +32,7 @@ use mas_storage::{ }, user::BrowserSessionRepository, }; +use mas_templates::{DeviceNameContext, TemplateContext, Templates}; use oauth2_types::{ errors::{ClientError, ClientErrorCode}, pkce::CodeChallengeError, @@ -261,6 +263,8 @@ impl IntoResponse for RouteError { } } +impl_from_error_for_route!(mas_i18n::DataError); +impl_from_error_for_route!(mas_templates::TemplateError); impl_from_error_for_route!(mas_storage::RepositoryError); impl_from_error_for_route!(mas_policy::EvaluationError); impl_from_error_for_route!(super::IdTokenSignatureError); @@ -281,6 +285,7 @@ pub(crate) async fn post( State(homeserver): State>, State(site_config): State, State(encrypter): State, + State(templates): State, policy: Policy, user_agent: Option>, client_authorization: ClientAuthorization, @@ -334,6 +339,7 @@ pub(crate) async fn post( &site_config, repo, &homeserver, + &templates, user_agent, ) .await? @@ -415,6 +421,7 @@ async fn authorization_code_grant( site_config: &SiteConfig, mut repo: BoxRepository, homeserver: &Arc, + templates: &Templates, user_agent: Option, ) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { // Check that the client is allowed to use this grant type @@ -482,6 +489,11 @@ async fn authorization_code_grant( .await? .ok_or(RouteError::NoSuchOAuthSession(session_id))?; + // Generate a device name + let lang: DataLocale = authz_grant.locale.as_deref().unwrap_or("en").parse()?; + let ctx = DeviceNameContext::new(client.clone(), user_agent.clone()).with_language(lang); + let device_name = templates.render_device_name(&ctx)?; + if let Some(user_agent) = user_agent { session = repo .oauth2_session() @@ -567,7 +579,7 @@ async fn authorization_code_grant( for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str(), None) + .create_device(&mxid, device.as_str(), Some(&device_name)) .await .map_err(RouteError::ProvisionDeviceFailed)?; } diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 200b5e1ff..44fb06a5e 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -11,7 +11,7 @@ mod translator; pub use icu_calendar; pub use icu_datetime; pub use icu_locid::locale; -pub use icu_provider::DataLocale; +pub use icu_provider::{DataError, DataLocale}; pub use self::{ sprintf::{Argument, ArgumentList, Message}, diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index e1ca20069..345c8bf01 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1564,6 +1564,39 @@ impl TemplateContext for AccountInactiveContext { } } +/// Context used by the `device_name.txt` template +#[derive(Serialize)] +pub struct DeviceNameContext { + client: Client, + raw_user_agent: String, +} + +impl DeviceNameContext { + /// Constructs a new context with a client and user agent + #[must_use] + pub fn new(client: Client, user_agent: Option) -> Self { + Self { + client, + raw_user_agent: user_agent.unwrap_or_default(), + } + } +} + +impl TemplateContext for DeviceNameContext { + fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + Client::samples(now, rng) + .into_iter() + .map(|client| DeviceNameContext { + client, + raw_user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned(), + }) + .collect() + } +} + /// Context used by the `form_post.html` template #[derive(Serialize)] pub struct FormPostContext { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 4c021f87f..c5d0f05e6 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -35,13 +35,13 @@ mod macros; pub use self::{ context::{ AccountInactiveContext, ApiDocContext, AppContext, CompatSsoContext, ConsentContext, - DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext, - EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, - LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext, - PolicyViolationContext, PostAuthContext, PostAuthContextInner, RecoveryExpiredContext, - RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext, - RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, - RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, + DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext, + EmailRecoveryContext, EmailVerificationContext, EmptyContext, ErrorContext, + FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, + PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, + RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, + RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, + RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, @@ -417,6 +417,9 @@ register_templates! { /// Render the 'account logged out' page pub fn render_account_logged_out(WithLanguage>) { "pages/account/logged_out.html" } + + /// Render the automatic device name for OAuth 2.0 client + pub fn render_device_name(WithLanguage) { "device_name.txt" } } impl Templates { @@ -459,6 +462,7 @@ impl Templates { check::render_upstream_oauth2_link_mismatch(self, now, rng)?; check::render_upstream_oauth2_suggest_link(self, now, rng)?; check::render_upstream_oauth2_do_register(self, now, rng)?; + check::render_device_name(self, now, rng)?; Ok(()) } } diff --git a/templates/device_name.txt b/templates/device_name.txt new file mode 100644 index 000000000..2c5e0b16f --- /dev/null +++ b/templates/device_name.txt @@ -0,0 +1,28 @@ +{# +Copyright 2024, 2025 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{%- set _ = translator(lang) -%} + +{%- set client_name = client.client_name or client.client_id -%} +{%- set user_agent = raw_user_agent | parse_user_agent() -%} + +{%- set device_name -%} + {%- if user_agent.model -%} + {{- user_agent.model -}} + {%- elif user_agent.name -%} + {%- if user_agent.os -%} + {{- _("mas.device_display_name.name_for_platform", name=user_agent.name, platform=user_agent.os) -}} + {%- else -%} + {{- user_agent.name -}} + {%- endif -%} + {%- else -%} + {{- _("mas.device_display_name.unknown_device") -}} + {%- endif -%} +{%- endset -%} + +{{- _("mas.device_display_name.client_on_device", client_name=client_name, device_name=device_name) -}} diff --git a/translations/en.json b/translations/en.json index 8c4a76e1c..5b2a5ad04 100644 --- a/translations/en.json +++ b/translations/en.json @@ -6,11 +6,11 @@ }, "cancel": "Cancel", "@cancel": { - "context": "pages/consent.html:69:11-29, pages/device_consent.html:126:13-31, pages/policy_violation.html:44:13-31" + "context": "pages/consent.html:69:11-29, pages/device_consent.html:127:13-31, pages/policy_violation.html:44:13-31" }, "continue": "Continue", "@continue": { - "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:123:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" + "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" }, "create_account": "Create Account", "@create_account": { @@ -22,7 +22,7 @@ }, "sign_out": "Sign out", "@sign_out": { - "context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:135:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" + "context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:136:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" }, "skip": "Skip", "@skip": { @@ -195,37 +195,37 @@ }, "heading": "Allow access to your account?", "@heading": { - "context": "pages/consent.html:25:27-51, pages/device_consent.html:27:29-53" + "context": "pages/consent.html:25:27-51, pages/device_consent.html:28:29-53" }, "make_sure_you_trust": "Make sure that you trust %(client_name)s.", "@make_sure_you_trust": { - "context": "pages/consent.html:38:81-142, pages/device_consent.html:103:83-144" + "context": "pages/consent.html:38:81-142, pages/device_consent.html:104:83-144" }, "this_will_allow": "This will allow %(client_name)s to:", "@this_will_allow": { - "context": "pages/consent.html:28:11-68, pages/device_consent.html:93:13-70" + "context": "pages/consent.html:28:11-68, pages/device_consent.html:94:13-70" }, "you_may_be_sharing": "You may be sharing sensitive information with this site or app.", "@you_may_be_sharing": { - "context": "pages/consent.html:39:7-42, pages/device_consent.html:104:9-44" + "context": "pages/consent.html:39:7-42, pages/device_consent.html:105:9-44" } }, "device_card": { "access_requested": "Access requested", "@access_requested": { - "context": "pages/device_consent.html:81:34-71" + "context": "pages/device_consent.html:82:34-71" }, "device_code": "Code", "@device_code": { - "context": "pages/device_consent.html:85:34-66" + "context": "pages/device_consent.html:86:34-66" }, "generic_device": "Device", "@generic_device": { - "context": "pages/device_consent.html:69:22-57" + "context": "pages/device_consent.html:70:22-57" }, "ip_address": "IP address", "@ip_address": { - "context": "pages/device_consent.html:76:36-67" + "context": "pages/device_consent.html:77:36-67" } }, "device_code_link": { @@ -241,29 +241,45 @@ "device_consent": { "another_device_access": "Another device wants to access your account.", "@another_device_access": { - "context": "pages/device_consent.html:92:13-58" + "context": "pages/device_consent.html:93:13-58" }, "denied": { "description": "You denied access to %(client_name)s. You can close this window.", "@description": { - "context": "pages/device_consent.html:146:27-94" + "context": "pages/device_consent.html:147:27-94" }, "heading": "Access denied", "@heading": { - "context": "pages/device_consent.html:145:29-67" + "context": "pages/device_consent.html:146:29-67" } }, "granted": { "description": "You granted access to %(client_name)s. You can close this window.", "@description": { - "context": "pages/device_consent.html:157:27-95" + "context": "pages/device_consent.html:158:27-95" }, "heading": "Access granted", "@heading": { - "context": "pages/device_consent.html:156:29-68" + "context": "pages/device_consent.html:157:29-68" } } }, + "device_display_name": { + "client_on_device": "%(client_name)s on %(device_name)s", + "@client_on_device": { + "context": "device_name.txt:28:4-99", + "description": "The automatic device name generated for a client, e.g. 'Element on iPhone'" + }, + "name_for_platform": "%(name)s for %(platform)s", + "@name_for_platform": { + "context": "device_name.txt:19:10-102", + "description": "Part of the automatic device name for the platfom, e.g. 'Safari for macOS'" + }, + "unknown_device": "Unknown device", + "@unknown_device": { + "context": "device_name.txt:24:8-51" + } + }, "email_in_use": { "description": "If you have forgotten your account credentials, you can recover your account. You can also start over and use a different email address.", "@description": { @@ -469,7 +485,7 @@ }, "not_you": "Not %(username)s?", "@not_you": { - "context": "pages/consent.html:62:11-67, pages/device_consent.html:132:13-69, pages/sso.html:42:11-67", + "context": "pages/consent.html:62:11-67, pages/device_consent.html:133:13-69, pages/sso.html:42:11-67", "description": "Suggestions for the user to log in as a different user" }, "or_separator": "Or", From 3b6581ab3d14c81831f0e6a218ab8b1b30cf9163 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Apr 2025 13:48:18 +0200 Subject: [PATCH 08/14] storage: add a user-provided human name to OAuth 2.0 sessions --- crates/data-model/src/oauth2/session.rs | 1 + ...46dbb28c11e41d86f22b3fa899a952cad00129e59bee6.json} | 10 ++++++++-- ...57c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json | 2 +- .../20250425113717_oauth2_session_human_name.sql | 8 ++++++++ crates/storage-pg/src/app_session.rs | 6 +++++- crates/storage-pg/src/iden.rs | 1 + crates/storage-pg/src/oauth2/session.rs | 8 ++++++++ 7 files changed, 32 insertions(+), 4 deletions(-) rename crates/storage-pg/.sqlx/{query-5a2e9b5002c1927c0035c22e393172b36ab46a4377b46618205151ea041886d5.json => query-6b8d28b76d7ab33178b46dbb28c11e41d86f22b3fa899a952cad00129e59bee6.json} (82%) create mode 100644 crates/storage-pg/migrations/20250425113717_oauth2_session_human_name.sql diff --git a/crates/data-model/src/oauth2/session.rs b/crates/data-model/src/oauth2/session.rs index 3024aa082..8a55aa863 100644 --- a/crates/data-model/src/oauth2/session.rs +++ b/crates/data-model/src/oauth2/session.rs @@ -83,6 +83,7 @@ pub struct Session { pub user_agent: Option, pub last_active_at: Option>, pub last_active_ip: Option, + pub human_name: Option, } impl std::ops::Deref for Session { diff --git a/crates/storage-pg/.sqlx/query-5a2e9b5002c1927c0035c22e393172b36ab46a4377b46618205151ea041886d5.json b/crates/storage-pg/.sqlx/query-6b8d28b76d7ab33178b46dbb28c11e41d86f22b3fa899a952cad00129e59bee6.json similarity index 82% rename from crates/storage-pg/.sqlx/query-5a2e9b5002c1927c0035c22e393172b36ab46a4377b46618205151ea041886d5.json rename to crates/storage-pg/.sqlx/query-6b8d28b76d7ab33178b46dbb28c11e41d86f22b3fa899a952cad00129e59bee6.json index 5fae1ffab..a7b95fc91 100644 --- a/crates/storage-pg/.sqlx/query-5a2e9b5002c1927c0035c22e393172b36ab46a4377b46618205151ea041886d5.json +++ b/crates/storage-pg/.sqlx/query-6b8d28b76d7ab33178b46dbb28c11e41d86f22b3fa899a952cad00129e59bee6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_session_id\n , user_id\n , user_session_id\n , oauth2_client_id\n , scope_list\n , created_at\n , finished_at\n , user_agent\n , last_active_at\n , last_active_ip as \"last_active_ip: IpAddr\"\n FROM oauth2_sessions\n\n WHERE oauth2_session_id = $1\n ", + "query": "\n SELECT oauth2_session_id\n , user_id\n , user_session_id\n , oauth2_client_id\n , scope_list\n , created_at\n , finished_at\n , user_agent\n , last_active_at\n , last_active_ip as \"last_active_ip: IpAddr\"\n , human_name\n FROM oauth2_sessions\n\n WHERE oauth2_session_id = $1\n ", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "last_active_ip: IpAddr", "type_info": "Inet" + }, + { + "ordinal": 10, + "name": "human_name", + "type_info": "Text" } ], "parameters": { @@ -69,8 +74,9 @@ true, true, true, + true, true ] }, - "hash": "5a2e9b5002c1927c0035c22e393172b36ab46a4377b46618205151ea041886d5" + "hash": "6b8d28b76d7ab33178b46dbb28c11e41d86f22b3fa899a952cad00129e59bee6" } diff --git a/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json b/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json index f5503fa0e..ef1ac0372 100644 --- a/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json +++ b/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json @@ -23,7 +23,7 @@ "Left": [] }, "nullable": [ - false, + true, true, null ] diff --git a/crates/storage-pg/migrations/20250425113717_oauth2_session_human_name.sql b/crates/storage-pg/migrations/20250425113717_oauth2_session_human_name.sql new file mode 100644 index 000000000..82a07c6d7 --- /dev/null +++ b/crates/storage-pg/migrations/20250425113717_oauth2_session_human_name.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Add a user-provided human name to OAuth 2.0 sessions +ALTER TABLE oauth2_sessions + ADD COLUMN human_name TEXT; diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index 1d759c2ba..cd5e40b53 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -192,6 +192,7 @@ impl TryFrom for AppSession { user_agent, last_active_at, last_active_ip, + human_name, }; Ok(AppSession::OAuth2(Box::new(session))) @@ -299,7 +300,10 @@ impl AppSessionRepository for PgAppSessionRepository<'_> { AppSessionLookupIden::ScopeList, ) .expr_as(Expr::cust("NULL"), AppSessionLookupIden::DeviceId) - .expr_as(Expr::cust("NULL"), AppSessionLookupIden::HumanName) + .expr_as( + Expr::col((OAuth2Sessions::Table, OAuth2Sessions::HumanName)), + AppSessionLookupIden::HumanName, + ) .expr_as( Expr::col((OAuth2Sessions::Table, OAuth2Sessions::CreatedAt)), AppSessionLookupIden::CreatedAt, diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index 71e6f7591..d64ce930e 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -83,6 +83,7 @@ pub enum OAuth2Sessions { UserAgent, LastActiveAt, LastActiveIp, + HumanName, } #[derive(sea_query::Iden)] diff --git a/crates/storage-pg/src/oauth2/session.rs b/crates/storage-pg/src/oauth2/session.rs index b525e22a0..feeb4a49f 100644 --- a/crates/storage-pg/src/oauth2/session.rs +++ b/crates/storage-pg/src/oauth2/session.rs @@ -55,6 +55,7 @@ struct OAuthSessionLookup { user_agent: Option, last_active_at: Option>, last_active_ip: Option, + human_name: Option, } impl TryFrom for Session { @@ -90,6 +91,7 @@ impl TryFrom for Session { user_agent: value.user_agent, last_active_at: value.last_active_at, last_active_ip: value.last_active_ip, + human_name: value.human_name, }) } } @@ -195,6 +197,7 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> { , user_agent , last_active_at , last_active_ip as "last_active_ip: IpAddr" + , human_name FROM oauth2_sessions WHERE oauth2_session_id = $1 @@ -270,6 +273,7 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> { user_agent: None, last_active_at: None, last_active_ip: None, + human_name: None, }) } @@ -392,6 +396,10 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> { Expr::col((OAuth2Sessions::Table, OAuth2Sessions::LastActiveIp)), OAuthSessionLookupIden::LastActiveIp, ) + .expr_as( + Expr::col((OAuth2Sessions::Table, OAuth2Sessions::HumanName)), + OAuthSessionLookupIden::HumanName, + ) .from(OAuth2Sessions::Table) .apply_filter(filter) .generate_pagination( From fe1d15ab0eb3eb0ac7edd39866a7691f701dfe24 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Apr 2025 13:51:36 +0200 Subject: [PATCH 09/14] graphql: expose the humanName field on OAuth 2.0 sessions --- crates/handlers/src/graphql/model/oauth.rs | 5 +++++ frontend/schema.graphql | 4 ++++ frontend/src/gql/graphql.ts | 2 ++ 3 files changed, 11 insertions(+) diff --git a/crates/handlers/src/graphql/model/oauth.rs b/crates/handlers/src/graphql/model/oauth.rs index 9c8dc5f1a..9ec94c288 100644 --- a/crates/handlers/src/graphql/model/oauth.rs +++ b/crates/handlers/src/graphql/model/oauth.rs @@ -128,6 +128,11 @@ impl OAuth2Session { pub async fn last_active_at(&self) -> Option> { self.0.last_active_at } + + /// The user-provided name for this session. + pub async fn human_name(&self) -> Option<&str> { + self.0.human_name.as_deref() + } } /// The application type advertised by the client. diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 3f5dbc8d3..43499efbc 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1064,6 +1064,10 @@ type Oauth2Session implements Node & CreationEvent { The last time the session was active. """ lastActiveAt: DateTime + """ + The user-provided name for this session. + """ + humanName: String } type Oauth2SessionConnection { diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 5cd9d7c8a..aaba78623 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -776,6 +776,8 @@ export type Oauth2Session = CreationEvent & Node & { createdAt: Scalars['DateTime']['output']; /** When the session ended. */ finishedAt?: Maybe; + /** The user-provided name for this session. */ + humanName?: Maybe; /** ID of the object. */ id: Scalars['ID']['output']; /** The last time the session was active. */ From 498c0ac3a750541bd546c1cb9eb843b000bb11da Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Apr 2025 13:54:49 +0200 Subject: [PATCH 10/14] admin: expose the sessions 'human_name' --- crates/handlers/src/admin/model.rs | 14 ++++++++ .../src/admin/v1/compat_sessions/get.rs | 7 ++-- .../src/admin/v1/compat_sessions/list.rs | 31 ++++++++++------- .../src/admin/v1/oauth2_sessions/get.rs | 7 ++-- .../src/admin/v1/oauth2_sessions/list.rs | 7 ++-- docs/api/spec.json | 34 ++++++++++++++----- 6 files changed, 70 insertions(+), 30 deletions(-) diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index ab3a9d8a6..df17e2d91 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -184,6 +184,9 @@ pub struct CompatSession { /// The time this session was finished pub finished_at: Option>, + + /// The user-provided name, if any + pub human_name: Option, } impl @@ -210,6 +213,7 @@ impl last_active_at: session.last_active_at, last_active_ip: session.last_active_ip, finished_at, + human_name: session.human_name, } } } @@ -237,6 +241,7 @@ impl CompatSession { last_active_at: Some(DateTime::default()), last_active_ip: Some([1, 2, 3, 4].into()), finished_at: None, + human_name: Some("Laptop".to_owned()), }, Self { id: Ulid::from_bytes([0x02; 16]), @@ -249,6 +254,7 @@ impl CompatSession { last_active_at: Some(DateTime::default()), last_active_ip: Some([1, 2, 3, 4].into()), finished_at: Some(DateTime::default()), + human_name: None, }, Self { id: Ulid::from_bytes([0x03; 16]), @@ -261,6 +267,7 @@ impl CompatSession { last_active_at: None, last_active_ip: None, finished_at: None, + human_name: None, }, ] } @@ -301,6 +308,9 @@ pub struct OAuth2Session { /// The last IP address used by the session last_active_ip: Option, + + /// The user-provided name, if any + human_name: Option, } impl From for OAuth2Session { @@ -316,6 +326,7 @@ impl From for OAuth2Session { user_agent: session.user_agent, last_active_at: session.last_active_at, last_active_ip: session.last_active_ip, + human_name: session.human_name, } } } @@ -335,6 +346,7 @@ impl OAuth2Session { user_agent: Some("Mozilla/5.0".to_owned()), last_active_at: Some(DateTime::default()), last_active_ip: Some("127.0.0.1".parse().unwrap()), + human_name: Some("Laptop".to_owned()), }, Self { id: Ulid::from_bytes([0x02; 16]), @@ -347,6 +359,7 @@ impl OAuth2Session { user_agent: None, last_active_at: None, last_active_ip: None, + human_name: None, }, Self { id: Ulid::from_bytes([0x03; 16]), @@ -359,6 +372,7 @@ impl OAuth2Session { user_agent: Some("Mozilla/5.0".to_owned()), last_active_at: Some(DateTime::default()), last_active_ip: Some("127.0.0.1".parse().unwrap()), + human_name: None, }, ] } diff --git a/crates/handlers/src/admin/v1/compat_sessions/get.rs b/crates/handlers/src/admin/v1/compat_sessions/get.rs index d27146d59..3d471d0ce 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/get.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/get.rs @@ -119,7 +119,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "data": { "type": "compat-session", @@ -133,7 +133,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": null + "finished_at": null, + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07" @@ -143,7 +144,7 @@ mod tests { "self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07" } } - "###); + "#); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/compat_sessions/list.rs b/crates/handlers/src/admin/v1/compat_sessions/list.rs index 5a47b0571..adf15d190 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/list.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/list.rs @@ -276,7 +276,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 2 @@ -294,7 +294,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": null + "finished_at": null, + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" @@ -312,7 +313,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": "2022-01-16T14:43:00Z" + "finished_at": "2022-01-16T14:43:00Z", + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" @@ -325,7 +327,7 @@ mod tests { "last": "/api/admin/v1/compat-sessions?page[last]=10" } } - "###); + "#); // Filter by user let request = Request::get(format!( @@ -337,7 +339,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -355,7 +357,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": null + "finished_at": null, + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" @@ -368,7 +371,7 @@ mod tests { "last": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" } } - "###); + "#); // Filter by status (active) let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=active") @@ -377,7 +380,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -395,7 +398,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": null + "finished_at": null, + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" @@ -408,7 +412,7 @@ mod tests { "last": "/api/admin/v1/compat-sessions?filter[status]=active&page[last]=10" } } - "###); + "#); // Filter by status (finished) let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=finished") @@ -417,7 +421,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -435,7 +439,8 @@ mod tests { "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": "2022-01-16T14:43:00Z" + "finished_at": "2022-01-16T14:43:00Z", + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" @@ -448,6 +453,6 @@ mod tests { "last": "/api/admin/v1/compat-sessions?filter[status]=finished&page[last]=10" } } - "###); + "#); } } diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/get.rs b/crates/handlers/src/admin/v1/oauth2_sessions/get.rs index e5e602c62..88f46ecff 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/get.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/get.rs @@ -110,7 +110,7 @@ mod tests { response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); assert_eq!(body["data"]["type"], "oauth2-session"); - insta::assert_json_snapshot!(body, @r###" + insta::assert_json_snapshot!(body, @r#" { "data": { "type": "oauth2-session", @@ -124,7 +124,8 @@ mod tests { "scope": "urn:mas:admin", "user_agent": null, "last_active_at": null, - "last_active_ip": null + "last_active_ip": null, + "human_name": null }, "links": { "self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY" @@ -134,7 +135,7 @@ mod tests { "self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY" } } - "###); + "#); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs index 6b75caadd..49b429243 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs @@ -331,7 +331,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - insta::assert_json_snapshot!(body, @r###" + insta::assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -349,7 +349,8 @@ mod tests { "scope": "urn:mas:admin", "user_agent": null, "last_active_at": null, - "last_active_ip": null + "last_active_ip": null, + "human_name": null }, "links": { "self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY" @@ -362,6 +363,6 @@ mod tests { "last": "/api/admin/v1/oauth2-sessions?page[last]=10" } } - "###); + "#); } } diff --git a/docs/api/spec.json b/docs/api/spec.json index 121022809..1c7be2995 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -132,7 +132,8 @@ "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", "last_active_ip": "1.2.3.4", - "finished_at": null + "finished_at": null, + "human_name": "Laptop" }, "links": { "self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081" @@ -150,7 +151,8 @@ "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", "last_active_ip": "1.2.3.4", - "finished_at": "1970-01-01T00:00:00Z" + "finished_at": "1970-01-01T00:00:00Z", + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2" @@ -168,7 +170,8 @@ "user_agent": null, "last_active_at": null, "last_active_ip": null, - "finished_at": null + "finished_at": null, + "human_name": null }, "links": { "self": "/api/admin/v1/compat-sessions/030C1G60R30C1G60R30C1G60R3" @@ -245,7 +248,8 @@ "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", "last_active_ip": "1.2.3.4", - "finished_at": null + "finished_at": null, + "human_name": "Laptop" }, "links": { "self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081" @@ -430,7 +434,8 @@ "scope": "openid", "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", - "last_active_ip": "127.0.0.1" + "last_active_ip": "127.0.0.1", + "human_name": "Laptop" }, "links": { "self": "/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081" @@ -448,7 +453,8 @@ "scope": "urn:mas:admin", "user_agent": null, "last_active_at": null, - "last_active_ip": null + "last_active_ip": null, + "human_name": null }, "links": { "self": "/api/admin/v1/oauth2-sessions/02081040G2081040G2081040G2" @@ -466,7 +472,8 @@ "scope": "urn:matrix:org.matrix.msc2967.client:api:*", "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", - "last_active_ip": "127.0.0.1" + "last_active_ip": "127.0.0.1", + "human_name": null }, "links": { "self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3" @@ -560,7 +567,8 @@ "scope": "openid", "user_agent": "Mozilla/5.0", "last_active_at": "1970-01-01T00:00:00Z", - "last_active_ip": "127.0.0.1" + "last_active_ip": "127.0.0.1", + "human_name": "Laptop" }, "links": { "self": "/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081" @@ -2726,6 +2734,11 @@ "type": "string", "format": "date-time", "nullable": true + }, + "human_name": { + "description": "The user-provided name, if any", + "type": "string", + "nullable": true } } }, @@ -3001,6 +3014,11 @@ "type": "string", "format": "ip", "nullable": true + }, + "human_name": { + "description": "The user-provided name, if any", + "type": "string", + "nullable": true } } }, From 0b47355398ba8631774f6ef222eb29460fb3e8dd Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Apr 2025 14:05:18 +0200 Subject: [PATCH 11/14] frontend: display the custom device name on OAuth 2.0 sessions --- frontend/src/components/OAuth2Session.tsx | 2 ++ .../components/SessionDetail/OAuth2SessionDetail.tsx | 2 ++ frontend/src/gql/gql.ts | 12 ++++++------ frontend/src/gql/graphql.ts | 8 ++++++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/OAuth2Session.tsx b/frontend/src/components/OAuth2Session.tsx index cc92f26c6..a72fa4aba 100644 --- a/frontend/src/components/OAuth2Session.tsx +++ b/frontend/src/components/OAuth2Session.tsx @@ -16,6 +16,7 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + humanName ...EndOAuth2SessionButton_session @@ -72,6 +73,7 @@ const OAuth2Session: React.FC = ({ session }) => { const clientName = data.client.clientName || data.client.clientId; const deviceName = + data.humanName ?? data.userAgent?.model ?? (data.userAgent?.name ? data.userAgent?.os diff --git a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx index 656067cd0..7f2f9a94c 100644 --- a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx +++ b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx @@ -23,6 +23,7 @@ export const FRAGMENT = graphql(/* GraphQL */ ` finishedAt lastActiveIp lastActiveAt + humanName ...EndOAuth2SessionButton_session @@ -54,6 +55,7 @@ const OAuth2SessionDetail: React.FC = ({ session }) => { const clientName = data.client.clientName || data.client.clientId; const deviceName = + data.humanName ?? data.userAgent?.model ?? (data.userAgent?.name ? data.userAgent?.os diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 0bc5e469f..9b9ff86b0 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -24,7 +24,7 @@ type Documents = { "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.CompatSession_SessionFragmentDoc, "\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": typeof types.Footer_SiteConfigFragmentDoc, "\n query Footer {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": typeof types.FooterDocument, - "\n fragment OAuth2Session_session 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 deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": typeof types.OAuth2Session_SessionFragmentDoc, + "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": typeof types.OAuth2Session_SessionFragmentDoc, "\n fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n": typeof types.PasswordCreationDoubleInput_SiteConfigFragmentDoc, "\n fragment EndBrowserSessionButton_session on BrowserSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n }\n": typeof types.EndBrowserSessionButton_SessionFragmentDoc, "\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n }\n }\n }\n": typeof types.EndBrowserSessionDocument, @@ -34,7 +34,7 @@ type Documents = { "\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n }\n }\n }\n": typeof types.EndOAuth2SessionDocument, "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n": typeof types.BrowserSession_DetailFragmentDoc, "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\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 OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\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 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, @@ -78,7 +78,7 @@ const documents: Documents = { "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_SessionFragmentDoc, "\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": types.Footer_SiteConfigFragmentDoc, "\n query Footer {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": types.FooterDocument, - "\n fragment OAuth2Session_session 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 deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc, + "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc, "\n fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n": types.PasswordCreationDoubleInput_SiteConfigFragmentDoc, "\n fragment EndBrowserSessionButton_session on BrowserSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n }\n": types.EndBrowserSessionButton_SessionFragmentDoc, "\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n }\n }\n }\n": types.EndBrowserSessionDocument, @@ -88,7 +88,7 @@ const documents: Documents = { "\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n }\n }\n }\n": types.EndOAuth2SessionDocument, "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n": types.BrowserSession_DetailFragmentDoc, "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\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 OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\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 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, @@ -162,7 +162,7 @@ export function graphql(source: "\n query Footer {\n siteConfig {\n id\ /** * 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 OAuth2Session_session 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 deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"): typeof import('./graphql').OAuth2Session_SessionFragmentDoc; +export function graphql(source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"): typeof import('./graphql').OAuth2Session_SessionFragmentDoc; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -202,7 +202,7 @@ export function graphql(source: "\n fragment CompatSession_detail on CompatSess /** * 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 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 import('./graphql').OAuth2Session_DetailFragmentDoc; +export function graphql(source: "\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\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 import('./graphql').OAuth2Session_DetailFragmentDoc; /** * 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 aaba78623..2f2d97e4a 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1669,7 +1669,7 @@ export type FooterQuery = { __typename?: 'Query', siteConfig: ( ) }; export type OAuth2Session_SessionFragment = ( - { __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, model?: string | null, os?: string | null, deviceType: DeviceType } | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, applicationType?: Oauth2ApplicationType | null, logoUri?: string | null } } + { __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, humanName?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, model?: string | null, os?: string | null, deviceType: DeviceType } | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, applicationType?: Oauth2ApplicationType | null, logoUri?: string | null } } & { ' $fragmentRefs'?: { 'EndOAuth2SessionButton_SessionFragment': EndOAuth2SessionButton_SessionFragment } } ) & { ' $fragmentName'?: 'OAuth2Session_SessionFragment' }; @@ -1713,7 +1713,7 @@ export type CompatSession_DetailFragment = ( ) & { ' $fragmentName'?: 'CompatSession_DetailFragment' }; export type OAuth2Session_DetailFragment = ( - { __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, model?: string | null, os?: string | null } | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, clientUri?: string | null, logoUri?: string | null } } + { __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, humanName?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, model?: string | null, os?: string | null } | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, clientUri?: string | null, logoUri?: string | null } } & { ' $fragmentRefs'?: { 'EndOAuth2SessionButton_SessionFragment': EndOAuth2SessionButton_SessionFragment } } ) & { ' $fragmentName'?: 'OAuth2Session_DetailFragment' }; @@ -2119,6 +2119,7 @@ export const OAuth2Session_SessionFragmentDoc = new TypedDocumentString(` finishedAt lastActiveIp lastActiveAt + humanName ...EndOAuth2SessionButton_session userAgent { name @@ -2221,6 +2222,7 @@ export const OAuth2Session_DetailFragmentDoc = new TypedDocumentString(` finishedAt lastActiveIp lastActiveAt + humanName ...EndOAuth2SessionButton_session userAgent { name @@ -2613,6 +2615,7 @@ fragment OAuth2Session_session on Oauth2Session { finishedAt lastActiveIp lastActiveAt + humanName ...EndOAuth2SessionButton_session userAgent { name @@ -2906,6 +2909,7 @@ fragment OAuth2Session_detail on Oauth2Session { finishedAt lastActiveIp lastActiveAt + humanName ...EndOAuth2SessionButton_session userAgent { name From 9a660b211a406e0b6ff06f222530aee094ab9726 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Apr 2025 14:39:34 +0200 Subject: [PATCH 12/14] storage: methods to set the sessions human name --- ...0ee8fca86b5cdce9320e190e3d3b8fd9f63bc.json | 15 ++++++++ ...320760971317c4519fae7af9d44e2be50985d.json | 15 ++++++++ crates/storage-pg/src/compat/session.rs | 34 +++++++++++++++++++ crates/storage-pg/src/oauth2/session.rs | 34 +++++++++++++++++++ crates/storage/src/compat/session.rs | 22 ++++++++++++ crates/storage/src/oauth2/session.rs | 18 ++++++++++ 6 files changed, 138 insertions(+) create mode 100644 crates/storage-pg/.sqlx/query-8afada5220fefb0d01ed6f87d3d0ee8fca86b5cdce9320e190e3d3b8fd9f63bc.json create mode 100644 crates/storage-pg/.sqlx/query-eb095f64bec5ac885683a8c6708320760971317c4519fae7af9d44e2be50985d.json diff --git a/crates/storage-pg/.sqlx/query-8afada5220fefb0d01ed6f87d3d0ee8fca86b5cdce9320e190e3d3b8fd9f63bc.json b/crates/storage-pg/.sqlx/query-8afada5220fefb0d01ed6f87d3d0ee8fca86b5cdce9320e190e3d3b8fd9f63bc.json new file mode 100644 index 000000000..44352005e --- /dev/null +++ b/crates/storage-pg/.sqlx/query-8afada5220fefb0d01ed6f87d3d0ee8fca86b5cdce9320e190e3d3b8fd9f63bc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth2_sessions\n SET human_name = $2\n WHERE oauth2_session_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8afada5220fefb0d01ed6f87d3d0ee8fca86b5cdce9320e190e3d3b8fd9f63bc" +} diff --git a/crates/storage-pg/.sqlx/query-eb095f64bec5ac885683a8c6708320760971317c4519fae7af9d44e2be50985d.json b/crates/storage-pg/.sqlx/query-eb095f64bec5ac885683a8c6708320760971317c4519fae7af9d44e2be50985d.json new file mode 100644 index 000000000..2ebaa4479 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-eb095f64bec5ac885683a8c6708320760971317c4519fae7af9d44e2be50985d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE compat_sessions\n SET human_name = $2\n WHERE compat_session_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "eb095f64bec5ac885683a8c6708320760971317c4519fae7af9d44e2be50985d" +} diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index a38b11689..5c99f4551 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -622,4 +622,38 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> { Ok(compat_session) } + + #[tracing::instrument( + name = "repository.compat_session.set_human_name", + skip(self), + fields( + compat_session.id = %compat_session.id, + compat_session.human_name = ?human_name, + ), + err, + )] + async fn set_human_name( + &mut self, + mut compat_session: CompatSession, + human_name: Option, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE compat_sessions + SET human_name = $2 + WHERE compat_session_id = $1 + "#, + Uuid::from(compat_session.id), + human_name.as_deref(), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + compat_session.human_name = human_name; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + Ok(compat_session) + } } diff --git a/crates/storage-pg/src/oauth2/session.rs b/crates/storage-pg/src/oauth2/session.rs index feeb4a49f..a6e00545f 100644 --- a/crates/storage-pg/src/oauth2/session.rs +++ b/crates/storage-pg/src/oauth2/session.rs @@ -526,4 +526,38 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> { Ok(session) } + + #[tracing::instrument( + name = "repository.oauth2_session.set_human_name", + skip(self), + fields( + client.id = %session.client_id, + session.human_name = ?human_name, + ), + err, + )] + async fn set_human_name( + &mut self, + mut session: Session, + human_name: Option, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE oauth2_sessions + SET human_name = $2 + WHERE oauth2_session_id = $1 + "#, + Uuid::from(session.id), + human_name.as_deref(), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + session.human_name = human_name; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + Ok(session) + } } diff --git a/crates/storage/src/compat/session.rs b/crates/storage/src/compat/session.rs index f8747d754..e935e986b 100644 --- a/crates/storage/src/compat/session.rs +++ b/crates/storage/src/compat/session.rs @@ -328,6 +328,22 @@ pub trait CompatSessionRepository: Send + Sync { compat_session: CompatSession, user_agent: String, ) -> Result; + + /// Set the human name of a compat session + /// + /// # Parameters + /// + /// * `compat_session`: The compat session to set the human name for + /// * `human_name`: The human name to set + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn set_human_name( + &mut self, + compat_session: CompatSession, + human_name: Option, + ) -> Result; } repository_impl!(CompatSessionRepository: @@ -374,4 +390,10 @@ repository_impl!(CompatSessionRepository: compat_session: CompatSession, user_agent: String, ) -> Result; + + async fn set_human_name( + &mut self, + compat_session: CompatSession, + human_name: Option, + ) -> Result; ); diff --git a/crates/storage/src/oauth2/session.rs b/crates/storage/src/oauth2/session.rs index 7dbba4a03..07f91a2b0 100644 --- a/crates/storage/src/oauth2/session.rs +++ b/crates/storage/src/oauth2/session.rs @@ -430,6 +430,18 @@ pub trait OAuth2SessionRepository: Send + Sync { session: Session, user_agent: String, ) -> Result; + + /// Set the human name of a [`Session`] + /// + /// # Parameters + /// + /// * `session`: The [`Session`] to set the human name for + /// * `human_name`: The human name to set + async fn set_human_name( + &mut self, + session: Session, + human_name: Option, + ) -> Result; } repository_impl!(OAuth2SessionRepository: @@ -489,4 +501,10 @@ repository_impl!(OAuth2SessionRepository: session: Session, user_agent: String, ) -> Result; + + async fn set_human_name( + &mut self, + session: Session, + human_name: Option, + ) -> Result; ); From 1696102592180bc126f0b78a622c78506d52bff7 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Apr 2025 14:50:17 +0200 Subject: [PATCH 13/14] graphql: add mutation to update device name --- .../src/graphql/mutations/compat_session.rs | 94 ++++++++++++++++++ .../src/graphql/mutations/oauth2_session.rs | 98 +++++++++++++++++++ frontend/schema.graphql | 84 ++++++++++++++++ frontend/src/gql/graphql.ts | 60 ++++++++++++ 4 files changed, 336 insertions(+) diff --git a/crates/handlers/src/graphql/mutations/compat_session.rs b/crates/handlers/src/graphql/mutations/compat_session.rs index 48bf23f81..3930b5670 100644 --- a/crates/handlers/src/graphql/mutations/compat_session.rs +++ b/crates/handlers/src/graphql/mutations/compat_session.rs @@ -64,6 +64,54 @@ impl EndCompatSessionPayload { } } +/// The input of the `setCompatSessionName` mutation. +#[derive(InputObject)] +pub struct SetCompatSessionNameInput { + /// The ID of the session to set the name of. + compat_session_id: ID, + + /// The new name of the session. + human_name: String, +} + +/// The payload of the `setCompatSessionName` mutation. +pub enum SetCompatSessionNamePayload { + /// The session was not found. + NotFound, + + /// The session was updated. + Updated(mas_data_model::CompatSession), +} + +/// The status of the `setCompatSessionName` mutation. +#[derive(Enum, Copy, Clone, PartialEq, Eq, Debug)] +enum SetCompatSessionNameStatus { + /// The session was updated. + Updated, + + /// The session was not found. + NotFound, +} + +#[Object] +impl SetCompatSessionNamePayload { + /// The status of the mutation. + async fn status(&self) -> SetCompatSessionNameStatus { + match self { + Self::Updated(_) => SetCompatSessionNameStatus::Updated, + Self::NotFound => SetCompatSessionNameStatus::NotFound, + } + } + + /// The session that was updated. + async fn oauth2_session(&self) -> Option { + match self { + Self::Updated(session) => Some(CompatSession::new(session.clone())), + Self::NotFound => None, + } + } +} + #[Object] impl CompatSessionMutations { async fn end_compat_session( @@ -105,4 +153,50 @@ impl CompatSessionMutations { Ok(EndCompatSessionPayload::Ended(Box::new(session))) } + + async fn set_compat_session_name( + &self, + ctx: &Context<'_>, + input: SetCompatSessionNameInput, + ) -> Result { + let state = ctx.state(); + let compat_session_id = NodeType::CompatSession.extract_ulid(&input.compat_session_id)?; + let requester = ctx.requester(); + + let mut repo = state.repository().await?; + let homeserver = state.homeserver_connection(); + + let session = repo.compat_session().lookup(compat_session_id).await?; + let Some(session) = session else { + return Ok(SetCompatSessionNamePayload::NotFound); + }; + + if !requester.is_owner_or_admin(&session) { + return Ok(SetCompatSessionNamePayload::NotFound); + } + + let user = repo + .user() + .lookup(session.user_id) + .await? + .context("User not found")?; + + let session = repo + .compat_session() + .set_human_name(session, Some(input.human_name.clone())) + .await?; + + // Update the device on the homeserver side + let mxid = homeserver.mxid(&user.username); + if let Some(device) = session.device.as_ref() { + homeserver + .update_device_display_name(&mxid, device.as_str(), &input.human_name) + .await + .context("Failed to provision device")?; + } + + repo.save().await?; + + Ok(SetCompatSessionNamePayload::Updated(session)) + } } diff --git a/crates/handlers/src/graphql/mutations/oauth2_session.rs b/crates/handlers/src/graphql/mutations/oauth2_session.rs index 058607536..1d0282014 100644 --- a/crates/handlers/src/graphql/mutations/oauth2_session.rs +++ b/crates/handlers/src/graphql/mutations/oauth2_session.rs @@ -110,6 +110,54 @@ impl EndOAuth2SessionPayload { } } +/// The input of the `setOauth2SessionName` mutation. +#[derive(InputObject)] +pub struct SetOAuth2SessionNameInput { + /// The ID of the session to set the name of. + oauth2_session_id: ID, + + /// The new name of the session. + human_name: String, +} + +/// The payload of the `setOauth2SessionName` mutation. +pub enum SetOAuth2SessionNamePayload { + /// The session was not found. + NotFound, + + /// The session was updated. + Updated(mas_data_model::Session), +} + +/// The status of the `setOauth2SessionName` mutation. +#[derive(Enum, Copy, Clone, PartialEq, Eq, Debug)] +enum SetOAuth2SessionNameStatus { + /// The session was updated. + Updated, + + /// The session was not found. + NotFound, +} + +#[Object] +impl SetOAuth2SessionNamePayload { + /// The status of the mutation. + async fn status(&self) -> SetOAuth2SessionNameStatus { + match self { + Self::Updated(_) => SetOAuth2SessionNameStatus::Updated, + Self::NotFound => SetOAuth2SessionNameStatus::NotFound, + } + } + + /// The session that was updated. + async fn oauth2_session(&self) -> Option { + match self { + Self::Updated(session) => Some(OAuth2Session(session.clone())), + Self::NotFound => None, + } + } +} + #[Object] impl OAuth2SessionMutations { /// Create a new arbitrary OAuth 2.0 Session. @@ -247,4 +295,54 @@ impl OAuth2SessionMutations { Ok(EndOAuth2SessionPayload::Ended(session)) } + + async fn set_oauth2_session_name( + &self, + ctx: &Context<'_>, + input: SetOAuth2SessionNameInput, + ) -> Result { + let state = ctx.state(); + let oauth2_session_id = NodeType::OAuth2Session.extract_ulid(&input.oauth2_session_id)?; + let requester = ctx.requester(); + + let mut repo = state.repository().await?; + let homeserver = state.homeserver_connection(); + + let session = repo.oauth2_session().lookup(oauth2_session_id).await?; + let Some(session) = session else { + return Ok(SetOAuth2SessionNamePayload::NotFound); + }; + + if !requester.is_owner_or_admin(&session) { + return Ok(SetOAuth2SessionNamePayload::NotFound); + } + + let user_id = session.user_id.context("Session has no user")?; + + let user = repo + .user() + .lookup(user_id) + .await? + .context("User not found")?; + + let session = repo + .oauth2_session() + .set_human_name(session, Some(input.human_name.clone())) + .await?; + + // Update the device on the homeserver side + let mxid = homeserver.mxid(&user.username); + for scope in &*session.scope { + if let Some(device) = Device::from_scope_token(scope) { + homeserver + .update_device_display_name(&mxid, device.as_str(), &input.human_name) + .await + .context("Failed to provision device")?; + } + } + + repo.save().await?; + + Ok(SetOAuth2SessionNamePayload::Updated(session)) + } } diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 43499efbc..8018e4f5b 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -941,7 +941,13 @@ type Mutation { input: CreateOAuth2SessionInput! ): CreateOAuth2SessionPayload! endOauth2Session(input: EndOAuth2SessionInput!): EndOAuth2SessionPayload! + setOauth2SessionName( + input: SetOAuth2SessionNameInput! + ): SetOAuth2SessionNamePayload! endCompatSession(input: EndCompatSessionInput!): EndCompatSessionPayload! + setCompatSessionName( + input: SetCompatSessionNameInput! + ): SetCompatSessionNamePayload! endBrowserSession(input: EndBrowserSessionInput!): EndBrowserSessionPayload! """ Set the display name of a user @@ -1434,6 +1440,45 @@ type SetCanRequestAdminPayload { user: User } +""" +The input of the `setCompatSessionName` mutation. +""" +input SetCompatSessionNameInput { + """ + The ID of the session to set the name of. + """ + compatSessionId: ID! + """ + The new name of the session. + """ + humanName: String! +} + +type SetCompatSessionNamePayload { + """ + The status of the mutation. + """ + status: SetCompatSessionNameStatus! + """ + The session that was updated. + """ + oauth2Session: CompatSession +} + +""" +The status of the `setCompatSessionName` mutation. +""" +enum SetCompatSessionNameStatus { + """ + The session was updated. + """ + UPDATED + """ + The session was not found. + """ + NOT_FOUND +} + """ The input for the `addEmail` mutation """ @@ -1476,6 +1521,45 @@ enum SetDisplayNameStatus { INVALID } +""" +The input of the `setOauth2SessionName` mutation. +""" +input SetOAuth2SessionNameInput { + """ + The ID of the session to set the name of. + """ + oauth2SessionId: ID! + """ + The new name of the session. + """ + humanName: String! +} + +type SetOAuth2SessionNamePayload { + """ + The status of the mutation. + """ + status: SetOAuth2SessionNameStatus! + """ + The session that was updated. + """ + oauth2Session: Oauth2Session +} + +""" +The status of the `setOauth2SessionName` mutation. +""" +enum SetOAuth2SessionNameStatus { + """ + The session was updated. + """ + UPDATED + """ + The session was not found. + """ + NOT_FOUND +} + """ The input for the `setPasswordByRecovery` mutation. """ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 2f2d97e4a..d875ef2bf 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -582,8 +582,10 @@ export type Mutation = { * administrators. */ setCanRequestAdmin: SetCanRequestAdminPayload; + setCompatSessionName: SetCompatSessionNamePayload; /** Set the display name of a user */ setDisplayName: SetDisplayNamePayload; + setOauth2SessionName: SetOAuth2SessionNamePayload; /** * Set the password for a user. * @@ -691,12 +693,24 @@ export type MutationSetCanRequestAdminArgs = { }; +/** The mutations root of the GraphQL interface. */ +export type MutationSetCompatSessionNameArgs = { + input: SetCompatSessionNameInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationSetDisplayNameArgs = { input: SetDisplayNameInput; }; +/** The mutations root of the GraphQL interface. */ +export type MutationSetOauth2SessionNameArgs = { + input: SetOAuth2SessionNameInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationSetPasswordArgs = { input: SetPasswordInput; @@ -1086,6 +1100,29 @@ export type SetCanRequestAdminPayload = { user?: Maybe; }; +/** The input of the `setCompatSessionName` mutation. */ +export type SetCompatSessionNameInput = { + /** The ID of the session to set the name of. */ + compatSessionId: Scalars['ID']['input']; + /** The new name of the session. */ + humanName: Scalars['String']['input']; +}; + +export type SetCompatSessionNamePayload = { + __typename?: 'SetCompatSessionNamePayload'; + /** The session that was updated. */ + oauth2Session?: Maybe; + /** The status of the mutation. */ + status: SetCompatSessionNameStatus; +}; + +/** The status of the `setCompatSessionName` mutation. */ +export type SetCompatSessionNameStatus = + /** The session was not found. */ + | 'NOT_FOUND' + /** The session was updated. */ + | 'UPDATED'; + /** The input for the `addEmail` mutation */ export type SetDisplayNameInput = { /** The display name to set. If `None`, the display name will be removed. */ @@ -1110,6 +1147,29 @@ export type SetDisplayNameStatus = /** The display name was set */ | 'SET'; +/** The input of the `setOauth2SessionName` mutation. */ +export type SetOAuth2SessionNameInput = { + /** The new name of the session. */ + humanName: Scalars['String']['input']; + /** The ID of the session to set the name of. */ + oauth2SessionId: Scalars['ID']['input']; +}; + +export type SetOAuth2SessionNamePayload = { + __typename?: 'SetOAuth2SessionNamePayload'; + /** The session that was updated. */ + oauth2Session?: Maybe; + /** The status of the mutation. */ + status: SetOAuth2SessionNameStatus; +}; + +/** The status of the `setOauth2SessionName` mutation. */ +export type SetOAuth2SessionNameStatus = + /** The session was not found. */ + | 'NOT_FOUND' + /** The session was updated. */ + | 'UPDATED'; + /** The input for the `setPasswordByRecovery` mutation. */ export type SetPasswordByRecoveryInput = { /** The new password for the user. */ From 4f9e75c33d98ddf4a46ecdae63c31f9a9c7722a7 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Apr 2025 15:35:41 +0200 Subject: [PATCH 14/14] frontend: allow setting custom names to sessions --- frontend/locales/en.json | 5 + .../CompatSessionDetail.test.tsx | 13 +- .../SessionDetail/CompatSessionDetail.tsx | 29 ++++- .../SessionDetail/EditSessionName.tsx | 100 +++++++++++++++ .../OAuth2SessionDetail.test.tsx | 9 +- .../SessionDetail/OAuth2SessionDetail.tsx | 26 ++++ .../CompatSessionDetail.test.tsx.snap | 121 ++++++++++++++---- .../OAuth2SessionDetail.test.tsx.snap | 64 ++++++++- frontend/src/gql/gql.ts | 12 ++ frontend/src/gql/graphql.ts | 78 +++++++++++ 10 files changed, 427 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/SessionDetail/EditSessionName.tsx diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 7f0343e85..405a4bacf 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -258,6 +258,11 @@ "last_active_label": "Last Active", "name_for_platform": "{{name}} for {{platform}}", "scopes_label": "Scopes", + "set_device_name": { + "help": "Set a name that will help you identify this device.", + "label": "Device name", + "title": "Edit device name" + }, "signed_in_label": "Signed in", "title": "Device details", "unknown_browser": "Unknown browser", diff --git a/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx b/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx index 30fb72418..9645c9e71 100644 --- a/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx +++ b/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx @@ -6,6 +6,7 @@ // @vitest-environment happy-dom +import { TooltipProvider } from "@vector-im/compound-web"; import { beforeAll, describe, expect, it } from "vitest"; import { makeFragmentData } from "../../gql"; import { mockLocale } from "../../test-utils/mockLocale"; @@ -33,7 +34,9 @@ describe("", () => { const data = makeFragmentData({ ...baseSession }, FRAGMENT); const { container, getByText, queryByText } = render( - , + + + , ); expect(container).toMatchSnapshot(); @@ -51,7 +54,9 @@ describe("", () => { ); const { container, getByText, queryByText } = render( - , + + + , ); expect(container).toMatchSnapshot(); @@ -69,7 +74,9 @@ describe("", () => { ); const { container, getByText, queryByText } = render( - , + + + , ); expect(container).toMatchSnapshot(); diff --git a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx index 47ab93d8d..17101a07b 100644 --- a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx +++ b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx @@ -4,17 +4,28 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { VisualList } from "@vector-im/compound-web"; import { parseISO } from "date-fns"; import { useTranslation } from "react-i18next"; import { type FragmentType, graphql, useFragment } from "../../gql"; +import { graphqlRequest } from "../../graphql"; import simplifyUrl from "../../utils/simplifyUrl"; import DateTime from "../DateTime"; import EndCompatSessionButton from "../Session/EndCompatSessionButton"; import LastActive from "../Session/LastActive"; +import EditSessionName from "./EditSessionName"; import SessionHeader from "./SessionHeader"; import * as Info from "./SessionInfo"; +const SET_SESSION_NAME_MUTATION = graphql(/* GraphQL */ ` + mutation SetCompatSessionName($sessionId: ID!, $displayName: String!) { + setCompatSessionName(input: { compatSessionId: $sessionId, humanName: $displayName }) { + status + } + } +`); + export const FRAGMENT = graphql(/* GraphQL */ ` fragment CompatSession_detail on CompatSession { id @@ -47,6 +58,19 @@ type Props = { const CompatSessionDetail: React.FC = ({ session }) => { const data = useFragment(FRAGMENT, session); const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const setDisplayName = useMutation({ + mutationFn: (displayName: string) => + graphqlRequest({ + query: SET_SESSION_NAME_MUTATION, + variables: { sessionId: data.id, displayName }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sessionDetail", data.id] }); + queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] }); + }, + }); const deviceName = data.userAgent?.model ?? @@ -67,7 +91,10 @@ const CompatSessionDetail: React.FC = ({ session }) => { return (
- {sessionName} + + {sessionName} + + {t("frontend.session.title")} diff --git a/frontend/src/components/SessionDetail/EditSessionName.tsx b/frontend/src/components/SessionDetail/EditSessionName.tsx new file mode 100644 index 000000000..4a57c34e8 --- /dev/null +++ b/frontend/src/components/SessionDetail/EditSessionName.tsx @@ -0,0 +1,100 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import IconEdit from "@vector-im/compound-design-tokens/assets/web/icons/edit"; +import { Button, Form, IconButton, Tooltip } from "@vector-im/compound-web"; +import { + type ComponentPropsWithoutRef, + forwardRef, + useRef, + useState, +} from "react"; +import * as Dialog from "../Dialog"; +import LoadingSpinner from "../LoadingSpinner"; + +import type { UseMutationResult } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +// This needs to be its own component because else props and refs aren't passed properly in the trigger +const EditButton = forwardRef< + HTMLButtonElement, + { label: string } & ComponentPropsWithoutRef<"button"> +>(({ label, ...props }, ref) => ( + + + + + +)); + +type Props = { + mutation: UseMutationResult; + deviceName: string; +}; + +const EditSessionName: React.FC = ({ mutation, deviceName }) => { + const { t } = useTranslation(); + const fieldRef = useRef(null); + const [open, setOpen] = useState(false); + + const onSubmit = async ( + event: React.FormEvent, + ): Promise => { + event.preventDefault(); + + const form = event.currentTarget; + const formData = new FormData(form); + const displayName = formData.get("name") as string; + await mutation.mutateAsync(displayName); + setOpen(false); + }; + return ( + } + open={open} + onOpenChange={(open) => { + // Reset the form when the dialog is opened or closed + fieldRef.current?.form?.reset(); + setOpen(open); + }} + > + {t("frontend.session.set_device_name.title")} + + + + {t("frontend.session.set_device_name.label")} + + + + + {t("frontend.session.set_device_name.help")} + + + + + {mutation.isPending && } + {t("action.save")} + + + + + + + + ); +}; + +export default EditSessionName; diff --git a/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx b/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx index 7f33da2bb..8aa60c6bd 100644 --- a/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx +++ b/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx @@ -11,6 +11,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { makeFragmentData } from "../../gql"; import { mockLocale } from "../../test-utils/mockLocale"; +import { TooltipProvider } from "@vector-im/compound-web"; import render from "../../test-utils/render"; import OAuth2SessionDetail, { FRAGMENT } from "./OAuth2SessionDetail"; @@ -39,7 +40,9 @@ describe("", () => { const data = makeFragmentData(baseSession, FRAGMENT); const { asFragment, getByText, queryByText } = render( - , + + + , ); expect(asFragment()).toMatchSnapshot(); @@ -57,7 +60,9 @@ describe("", () => { ); const { asFragment, getByText, queryByText } = render( - , + + + , ); expect(asFragment()).toMatchSnapshot(); diff --git a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx index 7f2f9a94c..2dc850d43 100644 --- a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx +++ b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx @@ -4,17 +4,28 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { parseISO } from "date-fns"; import { useTranslation } from "react-i18next"; import { type FragmentType, graphql, useFragment } from "../../gql"; +import { graphqlRequest } from "../../graphql"; import { getDeviceIdFromScope } from "../../utils/deviceIdFromScope"; import DateTime from "../DateTime"; import ClientAvatar from "../Session/ClientAvatar"; import EndOAuth2SessionButton from "../Session/EndOAuth2SessionButton"; import LastActive from "../Session/LastActive"; +import EditSessionName from "./EditSessionName"; import SessionHeader from "./SessionHeader"; import * as Info from "./SessionInfo"; +const SET_SESSION_NAME_MUTATION = graphql(/* GraphQL */ ` + mutation SetOAuth2SessionName($sessionId: ID!, $displayName: String!) { + setOauth2SessionName(input: { oauth2SessionId: $sessionId, humanName: $displayName }) { + status + } + } +`); + export const FRAGMENT = graphql(/* GraphQL */ ` fragment OAuth2Session_detail on Oauth2Session { id @@ -50,6 +61,19 @@ type Props = { const OAuth2SessionDetail: React.FC = ({ session }) => { const data = useFragment(FRAGMENT, session); const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const setDisplayName = useMutation({ + mutationFn: (displayName: string) => + graphqlRequest({ + query: SET_SESSION_NAME_MUTATION, + variables: { sessionId: data.id, displayName }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sessionDetail", data.id] }); + queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] }); + }, + }); const deviceId = getDeviceIdFromScope(data.scope); const clientName = data.client.clientName || data.client.clientId; @@ -70,7 +94,9 @@ const OAuth2SessionDetail: React.FC = ({ session }) => {
{clientName}: {deviceName} + + {t("frontend.session.title")} diff --git a/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap b/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap index 2fe6298f3..fbfd98192 100644 --- a/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap +++ b/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap @@ -27,9 +27,38 @@ exports[` > renders a compatability session details 1`] = `

- element.io - : - Unknown device + element.io: Unknown device +

> renders a compatability session details 1`] = `
> renders a compatability session without an ssoL Unknown device

-
  • -
    - Uri -
    -

    -

  • > renders a finished session details 1`] = ` class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112" > Element: Unknown device +
    > renders session details 1`] = ` class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112" > Element: Unknown device +
    > renders session details 1`] = `