feat: Allow devices to be removed from the account panel

This commit is contained in:
Ginger
2026-04-30 11:32:30 -04:00
parent ee73a2b36d
commit 6f17868525
9 changed files with 172 additions and 41 deletions
+11 -5
View File
@@ -7,10 +7,16 @@ use axum::{
extract::State,
routing::method_routing::{get, post},
};
use const_str::concat;
use serde_json::json;
pub(crate) use server_metadata::*;
const BASE_PATH: &str = const_str::concat!(conduwuit_core::ROUTE_PREFIX, "/oauth2/");
const BASE_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/oauth2/");
const AUTH_CODE_PATH: &str = "grant/authorization_code";
const JWKS_URI_PATH: &str = "client/keys.json";
const CLIENT_REGISTER_PATH: &str = "client/register";
const TOKEN_REVOKE_PATH: &str = "client/revoke";
const TOKEN_PATH: &str = "grant/token";
pub(crate) fn router() -> Router<crate::State> {
Router::new().nest(BASE_PATH, oauth_router())
@@ -24,9 +30,9 @@ pub(crate) fn router() -> Router<crate::State> {
fn oauth_router() -> Router<crate::State> {
Router::new()
.route(CLIENT_REGISTER_PATH, post(register_client::register_client_route))
.route(concat!("/", CLIENT_REGISTER_PATH), post(register_client::register_client_route))
// TODO(unspecced): used by old versions of the matrix-js-sdk
.route(JWKS_URI_PATH, get(async || Json(json!({"keys": []}))))
.route(TOKEN_PATH, post(token::token_route))
.route(TOKEN_REVOKE_PATH, post(token::revoke_token_route))
.route(concat!("/", JWKS_URI_PATH), get(async || Json(json!({"keys": []}))))
.route(concat!("/", TOKEN_PATH), post(token::token_route))
.route(concat!("/", TOKEN_REVOKE_PATH), post(token::revoke_token_route))
}
+6 -7
View File
@@ -4,13 +4,12 @@ use ruma::{api::client::discovery::get_authorization_server_metadata, serde::Raw
use serde_json::{Value, json};
use service::Services;
use crate::Ruma;
pub(super) const AUTH_CODE_PATH: &str = "grant/authorization_code";
pub(super) const JWKS_URI_PATH: &str = "client/keys.json";
pub(super) const CLIENT_REGISTER_PATH: &str = "client/register";
pub(super) const TOKEN_REVOKE_PATH: &str = "client/revoke";
pub(super) const TOKEN_PATH: &str = "grant/token";
use crate::{
Ruma,
client::oauth::{
AUTH_CODE_PATH, CLIENT_REGISTER_PATH, JWKS_URI_PATH, TOKEN_PATH, TOKEN_REVOKE_PATH,
},
};
pub(crate) async fn get_authorization_server_metadata_route(
State(services): State<crate::State>,
+3 -1
View File
@@ -3,7 +3,7 @@ use std::{any::Any, sync::Once, time::Duration};
use askama::Template;
use axum::{
Router,
extract::rejection::{FormRejection, QueryRejection},
extract::rejection::{FormRejection, PathRejection, QueryRejection},
http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
};
@@ -37,6 +37,8 @@ enum WebError {
#[error("{0}")]
FormRejection(#[from] FormRejection),
#[error("{0}")]
PathRejection(#[from] PathRejection),
#[error("{0}")]
BadRequest(String),
#[error("This page does not exist.")]
+75
View File
@@ -0,0 +1,75 @@
use axum::{
Router,
extract::{Path, State},
routing::on,
};
use futures::StreamExt;
use ruma::OwnedDeviceId;
use serde::{Deserialize, Serialize};
use crate::{
WebError,
extract::{Expect, PostForm},
pages::{GET_POST, Result, components::DeviceCard},
response,
session::{LoginTarget, User},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new().route("/{device}/remove", on(GET_POST, route_remove_device))
}
template! {
struct RemoveDevice use "remove_device.html.j2" {
body: RemoveDeviceBody
}
}
#[derive(Debug)]
enum RemoveDeviceBody {
Form {
device_card: Box<DeviceCard>,
last_device: bool,
},
Success,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct RemoveDevicePath {
pub device: OwnedDeviceId,
}
async fn route_remove_device(
State(services): State<crate::State>,
user: User,
Expect(Path(query)): Expect<Path<RemoveDevicePath>>,
PostForm(form): PostForm<()>,
) -> 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()));
};
if form.is_some() {
services
.users
.remove_device(&user_id, &device.device_id)
.await;
response!(RemoveDevice::new(&services, RemoveDeviceBody::Success))
} else {
let device_card = DeviceCard::for_device(&services, &user_id, device, false).await;
let last_device = services.users.all_devices_metadata(&user_id).count().await <= 1;
response!(RemoveDevice::new(&services, RemoveDeviceBody::Form {
device_card: Box::new(device_card),
last_device
}))
}
}
+23 -7
View File
@@ -1,4 +1,5 @@
use axum::{Router, extract::State, response::Response, routing::get};
use conduwuit_core::utils::{IterStream, stream::TryExpect};
use conduwuit_service::threepid::EmailRequirement;
use futures::StreamExt;
use ruma::{OwnedClientSecret, OwnedSessionId};
@@ -12,11 +13,12 @@ use crate::{
template,
};
mod cross_signing_reset;
mod deactivate;
mod email;
mod login;
mod password;
pub(crate) mod cross_signing_reset;
pub(crate) mod deactivate;
pub(crate) mod device;
pub(crate) mod email;
pub(crate) mod login;
pub(crate) mod password;
pub(crate) fn build() -> Router<crate::State> {
#[allow(clippy::wildcard_imports)]
@@ -29,6 +31,7 @@ pub(crate) fn build() -> Router<crate::State> {
.nest("/email/", email::build())
.nest("/cross_signing_reset", cross_signing_reset::build())
.nest("/deactivate", deactivate::build())
.nest("/device/", device::build())
}
#[derive(Deserialize, Serialize)]
@@ -64,11 +67,24 @@ async fn get_account(
let mut devices: Vec<_> = services
.users
.all_device_ids(&user_id)
.then(async |device_id| DeviceCard::for_device(&services, &user_id, device_id).await)
.then(async |device_id| {
services
.users
.get_device_metadata(&user_id, &device_id)
.await
})
.expect_ok()
.collect()
.await;
devices.sort_unstable_by(|a, b| a.last_seen_ts.cmp(&b.last_seen_ts).reverse());
response!(Account::new(&services, user_card, email_requirement, email, devices))
let device_cards = devices
.into_iter()
.stream()
.then(async |device| DeviceCard::for_device(&services, &user_id, device, true).await)
.collect()
.await;
response!(Account::new(&services, user_card, email_requirement, email, device_cards))
}
+9 -20
View File
@@ -4,7 +4,7 @@ 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 ruma::{OwnedDeviceId, OwnedUserId, UserId};
use ruma::{OwnedDeviceId, OwnedUserId, UserId, api::client::device::Device};
pub(super) mod form;
@@ -87,8 +87,8 @@ pub(super) struct DeviceCard {
pub display_name: Option<String>,
pub avatar: Avatar,
pub last_active: String,
pub last_seen_ts: Option<u64>,
pub oauth_metadata: Option<ClientMetadata>,
pub show_actions: bool,
}
impl HtmlSafe for DeviceCard {}
@@ -97,18 +97,13 @@ impl DeviceCard {
pub(super) async fn for_device(
services: &Services,
user_id: &UserId,
device_id: OwnedDeviceId,
device: Device,
show_actions: bool,
) -> Self {
let device = services
.users
.get_device_metadata(user_id, &device_id)
.await
.ok();
let oauth_metadata = async {
let client_id = services
.oauth
.get_client_id_for_device(user_id, &device_id)
.get_client_id_for_device(user_id, &device.device_id)
.await?;
Some(
@@ -124,11 +119,7 @@ impl DeviceCard {
let display_name = oauth_metadata
.as_ref()
.and_then(|metadata| metadata.client_name.clone())
.or_else(|| {
device
.as_ref()
.and_then(|device| device.display_name.clone())
});
.or_else(|| device.display_name.clone());
let avatar = {
let avatar_src = oauth_metadata
@@ -153,9 +144,7 @@ impl DeviceCard {
Avatar { avatar_type }
};
let last_seen_ts = device.as_ref().and_then(|device| device.last_seen_ts);
let last_active = last_seen_ts.map_or_else(
let last_active = device.last_seen_ts.map_or_else(
|| "unknown".to_owned(),
|last_seen_ts| {
last_seen_ts
@@ -169,12 +158,12 @@ impl DeviceCard {
);
Self {
device_id,
device_id: device.device_id,
display_name,
avatar,
last_active,
last_seen_ts: last_seen_ts.map(|last_seen_ts| last_seen_ts.as_secs().into()),
oauth_metadata,
show_actions,
}
}
}
@@ -21,5 +21,10 @@
<p>
Last active: {{ last_active }}
</p>
{% if show_actions %}
<p>
<a href="device/{{ device_id }}/remove">Remove</a>
</p>
{% endif %}
</div>
</div>
@@ -0,0 +1,36 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Remove a device
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>Remove a device <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
{% match body %}
{% when RemoveDeviceBody::Form { device_card, last_device } %}
{{ device_card }}
<p>
Do you want to remove this device from your account? It will be signed out immediately.
</p>
{% if last_device %}
<p>
<em class="negative">This is your last device.</em> If you remove it, you will need to use
your recovery key to recover your encrypted messages when you log in on a new device.
</p>
{% else %}
<p>
<em class="negative">This may be a destructive action.</em> Make sure you have access to
another confirmed device, or your recovery key, to avoid losing access to your encrypted messages.
</p>
{% endif %}
<form method="post">
<button type="submit">Remove this device</button>
</form>
{% when RemoveDeviceBody::Success %}
<p>
The device has been signed out successfully.
</p>
{% endmatch %}
</div>
{%- endblock -%}
+4 -1
View File
@@ -10,7 +10,7 @@ use ruma::{OwnedUserId, UserId};
use serde::{Deserialize, Serialize};
use tower_sessions::Session;
use crate::{ROUTE_PREFIX, WebError};
use crate::{ROUTE_PREFIX, WebError, pages::account::device::RemoveDevicePath};
pub(crate) mod store;
@@ -32,6 +32,7 @@ pub(crate) enum LoginTarget {
ChangeEmail,
CrossSigningReset,
Deactivate,
RemoveDevice(RemoveDevicePath),
}
impl PartialEq for LoginTarget {
@@ -51,6 +52,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(),
};
format!("{ROUTE_PREFIX}/{path}")