From 3e8403de647b8dab1735047266e5f5ab14f2e7f7 Mon Sep 17 00:00:00 2001 From: Ginger Date: Thu, 30 Apr 2026 14:55:21 -0400 Subject: [PATCH] feat: Add a page for viewing a device's details --- Cargo.lock | 1 + src/service/oauth/mod.rs | 31 +++---- src/service/uiaa/mod.rs | 2 +- src/service/users/dehydrated_device.rs | 1 - src/web/Cargo.toml | 1 + src/web/pages/account/device.rs | 61 ++++++++++++-- src/web/pages/account/mod.rs | 15 +++- src/web/pages/components/mod.rs | 80 ++++++++++++------- src/web/pages/oauth/grant.rs | 10 +-- .../_components/client_scopes.html.j2 | 10 +++ .../templates/_components/device_card.html.j2 | 30 +++---- src/web/pages/templates/device_info.html.j2 | 53 ++++++++++++ src/web/pages/templates/grant.html.j2 | 11 +-- src/web/session/mod.rs | 9 ++- 14 files changed, 221 insertions(+), 94 deletions(-) create mode 100644 src/web/pages/templates/_components/client_scopes.html.j2 create mode 100644 src/web/pages/templates/device_info.html.j2 diff --git a/Cargo.lock b/Cargo.lock index ab9d2a626..e0925d980 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1119,6 +1119,7 @@ dependencies = [ "rand 0.10.1", "ruma", "serde", + "serde_json", "serde_urlencoded", "thiserror", "tower-http", diff --git a/src/service/oauth/mod.rs b/src/service/oauth/mod.rs index 53fb76e09..17e7c7c83 100644 --- a/src/service/oauth/mod.rs +++ b/src/service/oauth/mod.rs @@ -48,14 +48,14 @@ struct Services { users: Dep, } -#[derive(Deserialize, Serialize)] -struct SessionInfo { - client_id: String, +#[derive(Debug, Deserialize, Serialize)] +pub struct SessionInfo { + pub client_id: String, + pub scopes: BTreeSet, current_refresh_token: String, - scopes: BTreeSet, } -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] struct RefreshTokenInfo { client_id: String, user_id: OwnedUserId, @@ -166,18 +166,17 @@ impl Service { .ok() } - pub async fn get_client_id_for_device( + pub async fn get_session_info_for_device( &self, user_id: &UserId, device_id: &DeviceId, - ) -> Option { + ) -> Option { self.db .userdeviceid_oauthsessioninfo .qry(&(user_id, device_id)) .await .deserialized::() .ok() - .map(|session| session.client_id) } pub async fn request_authorization_code( @@ -417,11 +416,11 @@ impl Service { assert_eq!(&client_id, &refresh_token_info.client_id, "refresh token client id mismatch"); let mut session_info = self - .db - .userdeviceid_oauthsessioninfo - .qry(&(&refresh_token_info.user_id, &refresh_token_info.device_id)) + .get_session_info_for_device( + &refresh_token_info.user_id, + &refresh_token_info.device_id, + ) .await - .deserialized::() .expect("session info should exist"); assert_eq!(&client_id, &session_info.client_id, "session info client id mismatch"); @@ -464,13 +463,7 @@ impl Service { } pub async fn remove_session(&self, user_id: &UserId, device_id: &DeviceId) { - let session_info = self - .db - .userdeviceid_oauthsessioninfo - .qry(&(user_id, device_id)) - .await - .deserialized::() - .ok(); + let session_info = self.get_session_info_for_device(user_id, device_id).await; if let Some(session_info) = session_info { self.db diff --git a/src/service/uiaa/mod.rs b/src/service/uiaa/mod.rs index 081cd58f7..3cf1b0ecd 100644 --- a/src/service/uiaa/mod.rs +++ b/src/service/uiaa/mod.rs @@ -257,7 +257,7 @@ impl Service { self .services .oauth - .get_client_id_for_device(initiator.user_id, device_id) + .get_session_info_for_device(initiator.user_id, device_id) .await .is_some() } else { diff --git a/src/service/users/dehydrated_device.rs b/src/service/users/dehydrated_device.rs index d54e0f368..c5cb66d95 100644 --- a/src/service/users/dehydrated_device.rs +++ b/src/service/users/dehydrated_device.rs @@ -139,7 +139,6 @@ pub async fn get_dehydrated_device_id(&self, user_id: &UserId) -> Result Result { self.db diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml index 767ec2c06..2137e6372 100644 --- a/src/web/Cargo.toml +++ b/src/web/Cargo.toml @@ -37,6 +37,7 @@ ruma.workspace = true thiserror.workspace = true tower-http.workspace = true serde.workspace = true +serde_json.workspace = true lettre.workspace = true memory-serve = "2.1.0" validator = { version = "0.20.0", features = ["derive"] } diff --git a/src/web/pages/account/device.rs b/src/web/pages/account/device.rs index f4d3f6382..112b6917c 100644 --- a/src/web/pages/account/device.rs +++ b/src/web/pages/account/device.rs @@ -1,8 +1,9 @@ use axum::{ Router, extract::{Path, State}, - routing::on, + routing::{get, on}, }; +use conduwuit_service::oauth::{SessionInfo, client_metadata::ClientMetadata}; use futures::StreamExt; use ruma::OwnedDeviceId; use serde::{Deserialize, Serialize}; @@ -10,14 +11,61 @@ use serde::{Deserialize, Serialize}; use crate::{ WebError, extract::{Expect, PostForm}, - pages::{GET_POST, Result, components::DeviceCard}, + pages::{ + GET_POST, Result, + components::{ClientScopes, DeviceCard, DeviceCardStyle}, + }, response, session::{LoginTarget, User}, template, }; pub(crate) fn build() -> Router { - Router::new().route("/{device}/remove", on(GET_POST, route_remove_device)) + Router::new() + .route("/{device}/", get(get_device_info)) + .route("/{device}/remove", on(GET_POST, route_remove_device)) +} + +template! { + struct DeviceInfo use "device_info.html.j2" { + device_card: DeviceCard, + client_metadata: Option<(ClientMetadata, SessionInfo)> + } +} + +async fn get_device_info( + State(services): State, + user: User, + Expect(Path(query)): Expect>, +) -> Result { + let user_id = user.expect(LoginTarget::RemoveDevice(query.clone()))?; + + let Ok(device) = services + .users + .get_device_metadata(&user_id, &query.device) + .await + else { + return response!(WebError::BadRequest("Unknown device".to_owned())); + }; + + let client_metadata = async { + let session_info = services + .oauth + .get_session_info_for_device(&user_id, &device.device_id) + .await?; + let client_metadata = services + .oauth + .get_client_metadata(&session_info.client_id) + .await?; + + Some((client_metadata, session_info)) + } + .await; + + let device_card = + DeviceCard::for_device(&services, &user_id, device, DeviceCardStyle::Detailed).await; + + response!(DeviceInfo::new(&services, device_card, client_metadata)) } template! { @@ -36,14 +84,14 @@ enum RemoveDeviceBody { } #[derive(Clone, Debug, Deserialize, Serialize)] -pub(crate) struct RemoveDevicePath { +pub(crate) struct DevicePath { pub device: OwnedDeviceId, } async fn route_remove_device( State(services): State, user: User, - Expect(Path(query)): Expect>, + Expect(Path(query)): Expect>, PostForm(form): PostForm<()>, ) -> Result { let user_id = user.expect(LoginTarget::RemoveDevice(query.clone()))?; @@ -64,7 +112,8 @@ async fn route_remove_device( response!(RemoveDevice::new(&services, RemoveDeviceBody::Success)) } else { - let device_card = DeviceCard::for_device(&services, &user_id, device, false).await; + let device_card = + DeviceCard::for_device(&services, &user_id, device, DeviceCardStyle::Minimal).await; let last_device = services.users.all_devices_metadata(&user_id).count().await <= 1; response!(RemoveDevice::new(&services, RemoveDeviceBody::Form { diff --git a/src/web/pages/account/mod.rs b/src/web/pages/account/mod.rs index 6acfdeade..09facd9b5 100644 --- a/src/web/pages/account/mod.rs +++ b/src/web/pages/account/mod.rs @@ -1,5 +1,5 @@ use axum::{Router, extract::State, response::Response, routing::get}; -use conduwuit_core::utils::{IterStream, stream::TryExpect}; +use conduwuit_core::utils::{IterStream, ReadyExt, stream::TryExpect}; use conduwuit_service::threepid::EmailRequirement; use futures::StreamExt; use ruma::{OwnedClientSecret, OwnedSessionId}; @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::{ WebError, - pages::components::{DeviceCard, UserCard}, + pages::components::{DeviceCard, DeviceCardStyle, UserCard}, response, session::{LoginTarget, User}, template, @@ -64,6 +64,8 @@ async fn get_account( let user_card = UserCard::for_local_user(&services, user_id.clone()).await; + let dehydrated_device_id = services.users.get_dehydrated_device_id(&user_id).await.ok(); + let mut devices: Vec<_> = services .users .all_device_ids(&user_id) @@ -74,6 +76,11 @@ async fn get_account( .await }) .expect_ok() + .ready_filter(|device| { + dehydrated_device_id + .as_ref() + .is_none_or(|id| device.device_id != *id) + }) .collect() .await; @@ -82,7 +89,9 @@ async fn get_account( let device_cards = devices .into_iter() .stream() - .then(async |device| DeviceCard::for_device(&services, &user_id, device, true).await) + .then(async |device| { + DeviceCard::for_device(&services, &user_id, device, DeviceCardStyle::Minimal).await + }) .collect() .await; diff --git a/src/web/pages/components/mod.rs b/src/web/pages/components/mod.rs index fa865a4fb..a95cf4502 100644 --- a/src/web/pages/components/mod.rs +++ b/src/web/pages/components/mod.rs @@ -1,9 +1,13 @@ -use std::time::SystemTime; +use std::{collections::BTreeSet, time::SystemTime}; use askama::{Template, filters::HtmlSafe}; use base64::Engine; use conduwuit_core::{result::FlatOk, utils}; -use conduwuit_service::{Services, media::mxc::Mxc, oauth::client_metadata::ClientMetadata}; +use conduwuit_service::{ + Services, + media::mxc::Mxc, + oauth::{client_metadata::ClientMetadata, grant::Scope}, +}; use ruma::{OwnedDeviceId, OwnedUserId, UserId, api::client::device::Device}; pub(super) mod form; @@ -59,6 +63,29 @@ impl Avatar { Self { avatar_type } } + + pub(super) fn for_device( + oauth_metadata: Option<&ClientMetadata>, + display_name: Option<&str>, + ) -> Self { + let avatar_src = oauth_metadata + .and_then(|metadata| metadata.logo_uri.as_ref()) + .map(|uri| uri.as_str().to_owned()); + + let avatar_type = if let Some(avatar_src) = avatar_src { + AvatarType::Image(avatar_src) + } else if let Some(initial) = display_name.and_then(|name| name.chars().next()) { + if oauth_metadata.is_some() { + AvatarType::Initial(initial) + } else { + AvatarType::Initial('❖') + } + } else { + AvatarType::Initial('?') + }; + + Self { avatar_type } + } } #[derive(Debug, Template)] @@ -88,28 +115,34 @@ pub(super) struct DeviceCard { pub avatar: Avatar, pub last_active: String, pub oauth_metadata: Option, - pub show_actions: bool, + pub style: DeviceCardStyle, } impl HtmlSafe for DeviceCard {} +#[derive(Debug, PartialEq, Eq)] +pub(super) enum DeviceCardStyle { + Minimal, + Detailed, +} + impl DeviceCard { pub(super) async fn for_device( services: &Services, user_id: &UserId, device: Device, - show_actions: bool, + style: DeviceCardStyle, ) -> Self { let oauth_metadata = async { - let client_id = services + let session_info = services .oauth - .get_client_id_for_device(user_id, &device.device_id) + .get_session_info_for_device(user_id, &device.device_id) .await?; Some( services .oauth - .get_client_metadata(&client_id) + .get_client_metadata(&session_info.client_id) .await .expect("client should exist"), ) @@ -121,28 +154,7 @@ impl DeviceCard { .and_then(|metadata| metadata.client_name.clone()) .or_else(|| device.display_name.clone()); - let avatar = { - let avatar_src = oauth_metadata - .as_ref() - .and_then(|metadata| metadata.logo_uri.as_ref()) - .map(|uri| uri.as_str().to_owned()); - - let avatar_type = if let Some(avatar_src) = avatar_src { - AvatarType::Image(avatar_src) - } else if let Some(initial) = - display_name.as_ref().and_then(|name| name.chars().next()) - { - if oauth_metadata.is_some() { - AvatarType::Initial(initial) - } else { - AvatarType::Initial('❖') - } - } else { - AvatarType::Initial('?') - }; - - Avatar { avatar_type } - }; + let avatar = Avatar::for_device(oauth_metadata.as_ref(), display_name.as_deref()); let last_active = device.last_seen_ts.map_or_else( || "unknown".to_owned(), @@ -163,7 +175,15 @@ impl DeviceCard { avatar, last_active, oauth_metadata, - show_actions, + style, } } } + +#[derive(Debug, Template)] +#[template(path = "_components/client_scopes.html.j2")] +pub(super) struct ClientScopes { + pub scopes: BTreeSet, +} + +impl HtmlSafe for ClientScopes {} diff --git a/src/web/pages/oauth/grant.rs b/src/web/pages/oauth/grant.rs index 500f09521..070bcd118 100644 --- a/src/web/pages/oauth/grant.rs +++ b/src/web/pages/oauth/grant.rs @@ -1,12 +1,10 @@ -use std::collections::BTreeSet; - use axum::{ Router, extract::{Query, State}, response::Redirect, routing::on, }; -use conduwuit_service::oauth::grant::{AuthorizationCodeQuery, Scope}; +use conduwuit_service::oauth::grant::AuthorizationCodeQuery; use ruma::OwnedUserId; use url::Url; @@ -15,7 +13,7 @@ use crate::{ extract::{Expect, PostForm}, pages::{ GET_POST, Result, - components::{Avatar, AvatarType}, + components::{Avatar, AvatarType, ClientScopes}, }, response, session::{LoginQuery, LoginTarget, User}, @@ -36,7 +34,7 @@ template! { client_avatar: Avatar, policy_uri: Option, tos_uri: Option, - scopes: BTreeSet + scopes: ClientScopes } } @@ -101,6 +99,6 @@ async fn route_authorization_code( client_avatar, client.policy_uri.clone(), client.tos_uri.clone(), - scopes, + ClientScopes { scopes }, )) } diff --git a/src/web/pages/templates/_components/client_scopes.html.j2 b/src/web/pages/templates/_components/client_scopes.html.j2 new file mode 100644 index 000000000..05df28ff8 --- /dev/null +++ b/src/web/pages/templates/_components/client_scopes.html.j2 @@ -0,0 +1,10 @@ +
    + {% for scope in scopes %} + {% match scope %} + {% when Scope::ClientApi %} +
  • Interact with Matrix on your behalf
  • + {% when Scope::Device(_) %} +
  • Connect to your Matrix account
  • + {% endmatch %} + {% endfor %} +
diff --git a/src/web/pages/templates/_components/device_card.html.j2 b/src/web/pages/templates/_components/device_card.html.j2 index f774fa2e5..a89c9815b 100644 --- a/src/web/pages/templates/_components/device_card.html.j2 +++ b/src/web/pages/templates/_components/device_card.html.j2 @@ -3,28 +3,30 @@

{% if let Some(display_name) = display_name %} - {% if let Some(metadata) = oauth_metadata %} - {{ display_name }} - {% else %} - {{ display_name }} - {% endif %} + {{ display_name }} {% else %} Unknown device {% endif %} - - • {{ device_id }} - {% if oauth_metadata.is_none() %} - (legacy) + {% if style == DeviceCardStyle::Detailed %} + + • {{ device_id }} + {% if let Some(metadata) = oauth_metadata %} + • Client website + {% else %} + (legacy) + {% endif %} + {% endif %} -

Last active: {{ last_active }}

- {% if show_actions %} -

- Remove -

+

+ {% if style == DeviceCardStyle::Detailed %} + Remove + {% else %} + Details {% endif %} +

diff --git a/src/web/pages/templates/device_info.html.j2 b/src/web/pages/templates/device_info.html.j2 new file mode 100644 index 000000000..d04d8eb65 --- /dev/null +++ b/src/web/pages/templates/device_info.html.j2 @@ -0,0 +1,53 @@ +{% extends "_layout.html.j2" %} + +{%- block title -%} +Device information +{%- endblock -%} + +{%- block content -%} +
+

About Device Back

+ {{ device_card }} + {% if let Some((client_metadata, _)) = client_metadata %} +
+

+ {% if let Some(tos_uri) = &client_metadata.tos_uri %} +  Terms of service + {% endif %} + {% if let Some(policy_uri) = &client_metadata.policy_uri %} + • Policies + {% endif %} +

+
+ {% endif %} +
+

+ {% if let Some((_, session_info)) = client_metadata %} + This device has permission to: + {{ ClientScopes { scopes: session_info.scopes.clone() } }} + {% else %} + This device can access and control all features of your Matrix account. +
+ This is a legacy device. Legacy devices + always have full access to your account. + {% endif %} +

+
+ {% if let Some((client_metadata, session_info)) = client_metadata %} +
+ Client registration +

+ OAuth client ID: {{ session_info.client_id }} +

+

+ Scopes: + {% for scope in session_info.scopes %} + {{ scope | safe }} + {% endfor %} + +

+
{{ serde_json::to_string_pretty(&client_metadata).unwrap() }}
+
+ {% endif %} +
+{%- endblock -%} diff --git a/src/web/pages/templates/grant.html.j2 b/src/web/pages/templates/grant.html.j2 index aee111886..e994596e8 100644 --- a/src/web/pages/templates/grant.html.j2 +++ b/src/web/pages/templates/grant.html.j2 @@ -26,16 +26,7 @@ Authorize client

{{ client_name }} ({{ client_uri.domain().unwrap() }}) would like your permission to: -

    - {% for scope in scopes %} - {% match scope %} - {% when Scope::ClientApi %} -
  • Interact with Matrix on your behalf
  • - {% when Scope::Device(_) %} -
  • Connect to your Matrix account
  • - {% endmatch %} - {% endfor %} -
+ {{ scopes }}

{% match (&policy_uri, &tos_uri) %} {% when (Some(policy_uri), Some(tos_uri)) %} diff --git a/src/web/session/mod.rs b/src/web/session/mod.rs index ab7a9ea4e..b2d07e2e9 100644 --- a/src/web/session/mod.rs +++ b/src/web/session/mod.rs @@ -10,7 +10,7 @@ use ruma::{OwnedUserId, UserId}; use serde::{Deserialize, Serialize}; use tower_sessions::Session; -use crate::{ROUTE_PREFIX, WebError, pages::account::device::RemoveDevicePath}; +use crate::{ROUTE_PREFIX, WebError, pages::account::device::DevicePath}; pub(crate) mod store; @@ -32,7 +32,8 @@ pub(crate) enum LoginTarget { ChangeEmail, CrossSigningReset, Deactivate, - RemoveDevice(RemoveDevicePath), + DeviceInfo(DevicePath), + RemoveDevice(DevicePath), } impl PartialEq for LoginTarget { @@ -52,8 +53,8 @@ impl LoginTarget { | Self::ChangeEmail => "account/email/change/".into(), | Self::CrossSigningReset => "account/cross_signing_reset".into(), | Self::Deactivate => "account/deactivate".into(), - | Self::RemoveDevice(path) => - format!("account/device/{}/remove", path.device,).into(), + | Self::DeviceInfo(path) => format!("account/device/{}/", path.device).into(), + | Self::RemoveDevice(path) => format!("account/device/{}/remove", path.device).into(), }; format!("{ROUTE_PREFIX}/{path}")