feat: Add a page for viewing a device's details

This commit is contained in:
Ginger
2026-05-26 14:27:09 -04:00
parent 2ef8a1edd7
commit 3e8403de64
14 changed files with 221 additions and 94 deletions
Generated
+1
View File
@@ -1119,6 +1119,7 @@ dependencies = [
"rand 0.10.1", "rand 0.10.1",
"ruma", "ruma",
"serde", "serde",
"serde_json",
"serde_urlencoded", "serde_urlencoded",
"thiserror", "thiserror",
"tower-http", "tower-http",
+12 -19
View File
@@ -48,14 +48,14 @@ struct Services {
users: Dep<users::Service>, users: Dep<users::Service>,
} }
#[derive(Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct SessionInfo { pub struct SessionInfo {
client_id: String, pub client_id: String,
pub scopes: BTreeSet<Scope>,
current_refresh_token: String, current_refresh_token: String,
scopes: BTreeSet<Scope>,
} }
#[derive(Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct RefreshTokenInfo { struct RefreshTokenInfo {
client_id: String, client_id: String,
user_id: OwnedUserId, user_id: OwnedUserId,
@@ -166,18 +166,17 @@ impl Service {
.ok() .ok()
} }
pub async fn get_client_id_for_device( pub async fn get_session_info_for_device(
&self, &self,
user_id: &UserId, user_id: &UserId,
device_id: &DeviceId, device_id: &DeviceId,
) -> Option<String> { ) -> Option<SessionInfo> {
self.db self.db
.userdeviceid_oauthsessioninfo .userdeviceid_oauthsessioninfo
.qry(&(user_id, device_id)) .qry(&(user_id, device_id))
.await .await
.deserialized::<SessionInfo>() .deserialized::<SessionInfo>()
.ok() .ok()
.map(|session| session.client_id)
} }
pub async fn request_authorization_code( 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"); assert_eq!(&client_id, &refresh_token_info.client_id, "refresh token client id mismatch");
let mut session_info = self let mut session_info = self
.db .get_session_info_for_device(
.userdeviceid_oauthsessioninfo &refresh_token_info.user_id,
.qry(&(&refresh_token_info.user_id, &refresh_token_info.device_id)) &refresh_token_info.device_id,
)
.await .await
.deserialized::<SessionInfo>()
.expect("session info should exist"); .expect("session info should exist");
assert_eq!(&client_id, &session_info.client_id, "session info client id mismatch"); 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) { pub async fn remove_session(&self, user_id: &UserId, device_id: &DeviceId) {
let session_info = self let session_info = self.get_session_info_for_device(user_id, device_id).await;
.db
.userdeviceid_oauthsessioninfo
.qry(&(user_id, device_id))
.await
.deserialized::<SessionInfo>()
.ok();
if let Some(session_info) = session_info { if let Some(session_info) = session_info {
self.db self.db
+1 -1
View File
@@ -257,7 +257,7 @@ impl Service {
self self
.services .services
.oauth .oauth
.get_client_id_for_device(initiator.user_id, device_id) .get_session_info_for_device(initiator.user_id, device_id)
.await .await
.is_some() .is_some()
} else { } else {
-1
View File
@@ -139,7 +139,6 @@ pub async fn get_dehydrated_device_id(&self, user_id: &UserId) -> Result<OwnedDe
level = "debug", level = "debug",
skip_all, skip_all,
fields(%user_id), fields(%user_id),
ret,
)] )]
pub async fn get_dehydrated_device(&self, user_id: &UserId) -> Result<DehydratedDevice> { pub async fn get_dehydrated_device(&self, user_id: &UserId) -> Result<DehydratedDevice> {
self.db self.db
+1
View File
@@ -37,6 +37,7 @@ ruma.workspace = true
thiserror.workspace = true thiserror.workspace = true
tower-http.workspace = true tower-http.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true
lettre.workspace = true lettre.workspace = true
memory-serve = "2.1.0" memory-serve = "2.1.0"
validator = { version = "0.20.0", features = ["derive"] } validator = { version = "0.20.0", features = ["derive"] }
+55 -6
View File
@@ -1,8 +1,9 @@
use axum::{ use axum::{
Router, Router,
extract::{Path, State}, extract::{Path, State},
routing::on, routing::{get, on},
}; };
use conduwuit_service::oauth::{SessionInfo, client_metadata::ClientMetadata};
use futures::StreamExt; use futures::StreamExt;
use ruma::OwnedDeviceId; use ruma::OwnedDeviceId;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -10,14 +11,61 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
WebError, WebError,
extract::{Expect, PostForm}, extract::{Expect, PostForm},
pages::{GET_POST, Result, components::DeviceCard}, pages::{
GET_POST, Result,
components::{ClientScopes, DeviceCard, DeviceCardStyle},
},
response, response,
session::{LoginTarget, User}, session::{LoginTarget, User},
template, template,
}; };
pub(crate) fn build() -> Router<crate::State> { pub(crate) fn build() -> Router<crate::State> {
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<crate::State>,
user: User,
Expect(Path(query)): Expect<Path<DevicePath>>,
) -> 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! { template! {
@@ -36,14 +84,14 @@ enum RemoveDeviceBody {
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct RemoveDevicePath { pub(crate) struct DevicePath {
pub device: OwnedDeviceId, pub device: OwnedDeviceId,
} }
async fn route_remove_device( async fn route_remove_device(
State(services): State<crate::State>, State(services): State<crate::State>,
user: User, user: User,
Expect(Path(query)): Expect<Path<RemoveDevicePath>>, Expect(Path(query)): Expect<Path<DevicePath>>,
PostForm(form): PostForm<()>, PostForm(form): PostForm<()>,
) -> Result { ) -> Result {
let user_id = user.expect(LoginTarget::RemoveDevice(query.clone()))?; let user_id = user.expect(LoginTarget::RemoveDevice(query.clone()))?;
@@ -64,7 +112,8 @@ async fn route_remove_device(
response!(RemoveDevice::new(&services, RemoveDeviceBody::Success)) response!(RemoveDevice::new(&services, RemoveDeviceBody::Success))
} else { } 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; let last_device = services.users.all_devices_metadata(&user_id).count().await <= 1;
response!(RemoveDevice::new(&services, RemoveDeviceBody::Form { response!(RemoveDevice::new(&services, RemoveDeviceBody::Form {
+12 -3
View File
@@ -1,5 +1,5 @@
use axum::{Router, extract::State, response::Response, routing::get}; 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 conduwuit_service::threepid::EmailRequirement;
use futures::StreamExt; use futures::StreamExt;
use ruma::{OwnedClientSecret, OwnedSessionId}; use ruma::{OwnedClientSecret, OwnedSessionId};
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
WebError, WebError,
pages::components::{DeviceCard, UserCard}, pages::components::{DeviceCard, DeviceCardStyle, UserCard},
response, response,
session::{LoginTarget, User}, session::{LoginTarget, User},
template, template,
@@ -64,6 +64,8 @@ async fn get_account(
let user_card = UserCard::for_local_user(&services, user_id.clone()).await; 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 let mut devices: Vec<_> = services
.users .users
.all_device_ids(&user_id) .all_device_ids(&user_id)
@@ -74,6 +76,11 @@ async fn get_account(
.await .await
}) })
.expect_ok() .expect_ok()
.ready_filter(|device| {
dehydrated_device_id
.as_ref()
.is_none_or(|id| device.device_id != *id)
})
.collect() .collect()
.await; .await;
@@ -82,7 +89,9 @@ async fn get_account(
let device_cards = devices let device_cards = devices
.into_iter() .into_iter()
.stream() .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() .collect()
.await; .await;
+50 -30
View File
@@ -1,9 +1,13 @@
use std::time::SystemTime; use std::{collections::BTreeSet, time::SystemTime};
use askama::{Template, filters::HtmlSafe}; use askama::{Template, filters::HtmlSafe};
use base64::Engine; use base64::Engine;
use conduwuit_core::{result::FlatOk, utils}; 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}; use ruma::{OwnedDeviceId, OwnedUserId, UserId, api::client::device::Device};
pub(super) mod form; pub(super) mod form;
@@ -59,6 +63,29 @@ impl Avatar {
Self { avatar_type } 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)] #[derive(Debug, Template)]
@@ -88,28 +115,34 @@ pub(super) struct DeviceCard {
pub avatar: Avatar, pub avatar: Avatar,
pub last_active: String, pub last_active: String,
pub oauth_metadata: Option<ClientMetadata>, pub oauth_metadata: Option<ClientMetadata>,
pub show_actions: bool, pub style: DeviceCardStyle,
} }
impl HtmlSafe for DeviceCard {} impl HtmlSafe for DeviceCard {}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum DeviceCardStyle {
Minimal,
Detailed,
}
impl DeviceCard { impl DeviceCard {
pub(super) async fn for_device( pub(super) async fn for_device(
services: &Services, services: &Services,
user_id: &UserId, user_id: &UserId,
device: Device, device: Device,
show_actions: bool, style: DeviceCardStyle,
) -> Self { ) -> Self {
let oauth_metadata = async { let oauth_metadata = async {
let client_id = services let session_info = services
.oauth .oauth
.get_client_id_for_device(user_id, &device.device_id) .get_session_info_for_device(user_id, &device.device_id)
.await?; .await?;
Some( Some(
services services
.oauth .oauth
.get_client_metadata(&client_id) .get_client_metadata(&session_info.client_id)
.await .await
.expect("client should exist"), .expect("client should exist"),
) )
@@ -121,28 +154,7 @@ impl DeviceCard {
.and_then(|metadata| metadata.client_name.clone()) .and_then(|metadata| metadata.client_name.clone())
.or_else(|| device.display_name.clone()); .or_else(|| device.display_name.clone());
let avatar = { let avatar = Avatar::for_device(oauth_metadata.as_ref(), display_name.as_deref());
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 last_active = device.last_seen_ts.map_or_else( let last_active = device.last_seen_ts.map_or_else(
|| "unknown".to_owned(), || "unknown".to_owned(),
@@ -163,7 +175,15 @@ impl DeviceCard {
avatar, avatar,
last_active, last_active,
oauth_metadata, oauth_metadata,
show_actions, style,
} }
} }
} }
#[derive(Debug, Template)]
#[template(path = "_components/client_scopes.html.j2")]
pub(super) struct ClientScopes {
pub scopes: BTreeSet<Scope>,
}
impl HtmlSafe for ClientScopes {}
+4 -6
View File
@@ -1,12 +1,10 @@
use std::collections::BTreeSet;
use axum::{ use axum::{
Router, Router,
extract::{Query, State}, extract::{Query, State},
response::Redirect, response::Redirect,
routing::on, routing::on,
}; };
use conduwuit_service::oauth::grant::{AuthorizationCodeQuery, Scope}; use conduwuit_service::oauth::grant::AuthorizationCodeQuery;
use ruma::OwnedUserId; use ruma::OwnedUserId;
use url::Url; use url::Url;
@@ -15,7 +13,7 @@ use crate::{
extract::{Expect, PostForm}, extract::{Expect, PostForm},
pages::{ pages::{
GET_POST, Result, GET_POST, Result,
components::{Avatar, AvatarType}, components::{Avatar, AvatarType, ClientScopes},
}, },
response, response,
session::{LoginQuery, LoginTarget, User}, session::{LoginQuery, LoginTarget, User},
@@ -36,7 +34,7 @@ template! {
client_avatar: Avatar, client_avatar: Avatar,
policy_uri: Option<Url>, policy_uri: Option<Url>,
tos_uri: Option<Url>, tos_uri: Option<Url>,
scopes: BTreeSet<Scope> scopes: ClientScopes
} }
} }
@@ -101,6 +99,6 @@ async fn route_authorization_code(
client_avatar, client_avatar,
client.policy_uri.clone(), client.policy_uri.clone(),
client.tos_uri.clone(), client.tos_uri.clone(),
scopes, ClientScopes { scopes },
)) ))
} }
@@ -0,0 +1,10 @@
<ul>
{% for scope in scopes %}
{% match scope %}
{% when Scope::ClientApi %}
<li>Interact with Matrix on your behalf</li>
{% when Scope::Device(_) %}
<li>Connect to your Matrix account</li>
{% endmatch %}
{% endfor %}
</ul>
@@ -3,28 +3,30 @@
<div class="info"> <div class="info">
<p class="name"> <p class="name">
{% if let Some(display_name) = display_name %} {% if let Some(display_name) = display_name %}
{% if let Some(metadata) = oauth_metadata %}
<a href="{{ metadata.client_uri }}">{{ display_name }}</a>
{% else %}
{{ display_name }} {{ display_name }}
{% endif %}
{% else %} {% else %}
Unknown device Unknown device
{% endif %} {% endif %}
{% if style == DeviceCardStyle::Detailed %}
<span class="id"> <span class="id">
&bullet;&nbsp;{{ device_id }} &bullet;&nbsp;{{ device_id }}
{% if oauth_metadata.is_none() %} {% if let Some(metadata) = oauth_metadata %}
&bullet;&nbsp;<a href="{{ metadata.client_uri }}">Client website</a>
{% else %}
(legacy) (legacy)
{% endif %} {% endif %}
</span> </span>
{% endif %}
</p> </p>
<p> <p>
Last active: {{ last_active }} Last active: {{ last_active }}
</p> </p>
{% if show_actions %}
<p> <p>
<a href="device/{{ device_id }}/remove">Remove</a> {% if style == DeviceCardStyle::Detailed %}
</p> <a href="{{ crate::ROUTE_PREFIX }}/account/device/{{ device_id }}/remove">Remove</a>
{% else %}
<a href="{{ crate::ROUTE_PREFIX }}/account/device/{{ device_id }}/">Details</a>
{% endif %} {% endif %}
</p>
</div> </div>
</div> </div>
@@ -0,0 +1,53 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Device information
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>About Device <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
{{ device_card }}
{% if let Some((client_metadata, _)) = client_metadata %}
<section>
<p>
{% if let Some(tos_uri) = &client_metadata.tos_uri %}
&nbsp;<a href="{{ tos_uri }}">Terms of service</a>
{% endif %}
{% if let Some(policy_uri) = &client_metadata.policy_uri %}
&bullet;&nbsp;<a href="{{ policy_uri }}">Policies</a>
{% endif %}
</p>
</section>
{% endif %}
<section>
<p>
{% 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.
<br>
<small>❖ <i>This is a legacy device. Legacy devices
always have full access to your account.</i></small>
{% endif %}
</p>
</section>
{% if let Some((client_metadata, session_info)) = client_metadata %}
<details>
<summary>Client registration</summary>
<p>
OAuth client ID: <code>{{ session_info.client_id }}</code>
</p>
<p>
Scopes: <code>
{% for scope in session_info.scopes %}
{{ scope | safe }}
{% endfor %}
</code>
</p>
<pre><code>{{ serde_json::to_string_pretty(&client_metadata).unwrap() }}</code></pre>
</details>
{% endif %}
</div>
{%- endblock -%}
+1 -10
View File
@@ -26,16 +26,7 @@ Authorize client
<p> <p>
<b>{{ client_name }}</b> (<a href="{{ client_uri }}">{{ client_uri.domain().unwrap() }}</a>) would like <b>{{ client_name }}</b> (<a href="{{ client_uri }}">{{ client_uri.domain().unwrap() }}</a>) would like
your permission to: your permission to:
<ul> {{ scopes }}
{% for scope in scopes %}
{% match scope %}
{% when Scope::ClientApi %}
<li>Interact with Matrix on your behalf</li>
{% when Scope::Device(_) %}
<li>Connect to your Matrix account</li>
{% endmatch %}
{% endfor %}
</ul>
</p> </p>
{% match (&policy_uri, &tos_uri) %} {% match (&policy_uri, &tos_uri) %}
{% when (Some(policy_uri), Some(tos_uri)) %} {% when (Some(policy_uri), Some(tos_uri)) %}
+5 -4
View File
@@ -10,7 +10,7 @@ use ruma::{OwnedUserId, UserId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tower_sessions::Session; 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; pub(crate) mod store;
@@ -32,7 +32,8 @@ pub(crate) enum LoginTarget {
ChangeEmail, ChangeEmail,
CrossSigningReset, CrossSigningReset,
Deactivate, Deactivate,
RemoveDevice(RemoveDevicePath), DeviceInfo(DevicePath),
RemoveDevice(DevicePath),
} }
impl PartialEq for LoginTarget { impl PartialEq for LoginTarget {
@@ -52,8 +53,8 @@ impl LoginTarget {
| Self::ChangeEmail => "account/email/change/".into(), | Self::ChangeEmail => "account/email/change/".into(),
| Self::CrossSigningReset => "account/cross_signing_reset".into(), | Self::CrossSigningReset => "account/cross_signing_reset".into(),
| Self::Deactivate => "account/deactivate".into(), | Self::Deactivate => "account/deactivate".into(),
| Self::RemoveDevice(path) => | Self::DeviceInfo(path) => format!("account/device/{}/", path.device).into(),
format!("account/device/{}/remove", path.device,).into(), | Self::RemoveDevice(path) => format!("account/device/{}/remove", path.device).into(),
}; };
format!("{ROUTE_PREFIX}/{path}") format!("{ROUTE_PREFIX}/{path}")