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

This commit is contained in:
Ginger
2026-04-30 14:55:21 -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",
"ruma",
"serde",
"serde_json",
"serde_urlencoded",
"thiserror",
"tower-http",
+12 -19
View File
@@ -48,14 +48,14 @@ struct Services {
users: Dep<users::Service>,
}
#[derive(Deserialize, Serialize)]
struct SessionInfo {
client_id: String,
#[derive(Debug, Deserialize, Serialize)]
pub struct SessionInfo {
pub client_id: String,
pub scopes: BTreeSet<Scope>,
current_refresh_token: String,
scopes: BTreeSet<Scope>,
}
#[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<String> {
) -> Option<SessionInfo> {
self.db
.userdeviceid_oauthsessioninfo
.qry(&(user_id, device_id))
.await
.deserialized::<SessionInfo>()
.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::<SessionInfo>()
.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::<SessionInfo>()
.ok();
let session_info = self.get_session_info_for_device(user_id, device_id).await;
if let Some(session_info) = session_info {
self.db
+1 -1
View File
@@ -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 {
-1
View File
@@ -139,7 +139,6 @@ pub async fn get_dehydrated_device_id(&self, user_id: &UserId) -> Result<OwnedDe
level = "debug",
skip_all,
fields(%user_id),
ret,
)]
pub async fn get_dehydrated_device(&self, user_id: &UserId) -> Result<DehydratedDevice> {
self.db
+1
View File
@@ -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"] }
+55 -6
View File
@@ -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<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! {
@@ -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<crate::State>,
user: User,
Expect(Path(query)): Expect<Path<RemoveDevicePath>>,
Expect(Path(query)): Expect<Path<DevicePath>>,
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 {
+12 -3
View File
@@ -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;
+50 -30
View File
@@ -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<ClientMetadata>,
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<Scope>,
}
impl HtmlSafe for ClientScopes {}
+4 -6
View File
@@ -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<Url>,
tos_uri: Option<Url>,
scopes: BTreeSet<Scope>
scopes: ClientScopes
}
}
@@ -101,6 +99,6 @@ async fn route_authorization_code(
client_avatar,
client.policy_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">
<p class="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 }}
{% endif %}
{{ display_name }}
{% else %}
Unknown device
{% endif %}
<span class="id">
&bullet;&nbsp;{{ device_id }}
{% if oauth_metadata.is_none() %}
(legacy)
{% if style == DeviceCardStyle::Detailed %}
<span class="id">
&bullet;&nbsp;{{ device_id }}
{% if let Some(metadata) = oauth_metadata %}
&bullet;&nbsp;<a href="{{ metadata.client_uri }}">Client website</a>
{% else %}
(legacy)
{% endif %}
</span>
{% endif %}
</span>
</p>
<p>
Last active: {{ last_active }}
</p>
{% if show_actions %}
<p>
<a href="device/{{ device_id }}/remove">Remove</a>
</p>
<p>
{% if style == DeviceCardStyle::Detailed %}
<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 %}
</p>
</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>
<b>{{ client_name }}</b> (<a href="{{ client_uri }}">{{ client_uri.domain().unwrap() }}</a>) would like
your permission to:
<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>
{{ scopes }}
</p>
{% match (&policy_uri, &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 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}")