mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-05-26 20:49:55 +00:00
feat: Add a page for viewing a device's details
This commit is contained in:
Generated
+1
@@ -1119,6 +1119,7 @@ dependencies = [
|
||||
"rand 0.10.1",
|
||||
"ruma",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"thiserror",
|
||||
"tower-http",
|
||||
|
||||
+12
-19
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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">
|
||||
• {{ device_id }}
|
||||
{% if oauth_metadata.is_none() %}
|
||||
(legacy)
|
||||
{% if style == DeviceCardStyle::Detailed %}
|
||||
<span class="id">
|
||||
• {{ device_id }}
|
||||
{% if let Some(metadata) = oauth_metadata %}
|
||||
• <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 %}
|
||||
<a href="{{ tos_uri }}">Terms of service</a>
|
||||
{% endif %}
|
||||
{% if let Some(policy_uri) = &client_metadata.policy_uri %}
|
||||
• <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 -%}
|
||||
@@ -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)) %}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user