diff --git a/src/api/client/oauth/mod.rs b/src/api/client/oauth/mod.rs index e9b5fcb5b..f8a30ec46 100644 --- a/src/api/client/oauth/mod.rs +++ b/src/api/client/oauth/mod.rs @@ -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 { Router::new().nest(BASE_PATH, oauth_router()) @@ -24,9 +30,9 @@ pub(crate) fn router() -> Router { fn oauth_router() -> Router { 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)) } diff --git a/src/api/client/oauth/server_metadata.rs b/src/api/client/oauth/server_metadata.rs index 2993bce8f..e3aa1c1bb 100644 --- a/src/api/client/oauth/server_metadata.rs +++ b/src/api/client/oauth/server_metadata.rs @@ -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, diff --git a/src/web/mod.rs b/src/web/mod.rs index ebe4b7cb6..0015be3cb 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -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.")] diff --git a/src/web/pages/account/device.rs b/src/web/pages/account/device.rs new file mode 100644 index 000000000..f4d3f6382 --- /dev/null +++ b/src/web/pages/account/device.rs @@ -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 { + 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, + last_device: bool, + }, + Success, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RemoveDevicePath { + pub device: OwnedDeviceId, +} + +async fn route_remove_device( + State(services): State, + user: User, + Expect(Path(query)): Expect>, + 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 + })) + } +} diff --git a/src/web/pages/account/mod.rs b/src/web/pages/account/mod.rs index e3c3ab079..6acfdeade 100644 --- a/src/web/pages/account/mod.rs +++ b/src/web/pages/account/mod.rs @@ -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 { #[allow(clippy::wildcard_imports)] @@ -29,6 +31,7 @@ pub(crate) fn build() -> Router { .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)) } diff --git a/src/web/pages/components/mod.rs b/src/web/pages/components/mod.rs index 25377100c..fa865a4fb 100644 --- a/src/web/pages/components/mod.rs +++ b/src/web/pages/components/mod.rs @@ -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, pub avatar: Avatar, pub last_active: String, - pub last_seen_ts: Option, pub oauth_metadata: Option, + 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, } } } diff --git a/src/web/pages/templates/_components/device_card.html.j2 b/src/web/pages/templates/_components/device_card.html.j2 index 7de1e8e2f..f774fa2e5 100644 --- a/src/web/pages/templates/_components/device_card.html.j2 +++ b/src/web/pages/templates/_components/device_card.html.j2 @@ -21,5 +21,10 @@

Last active: {{ last_active }}

+ {% if show_actions %} +

+ Remove +

+ {% endif %} diff --git a/src/web/pages/templates/remove_device.html.j2 b/src/web/pages/templates/remove_device.html.j2 new file mode 100644 index 000000000..64eb3909c --- /dev/null +++ b/src/web/pages/templates/remove_device.html.j2 @@ -0,0 +1,36 @@ +{% extends "_layout.html.j2" %} + +{%- block title -%} +Remove a device +{%- endblock -%} + +{%- block content -%} +
+

Remove a device Back

+ {% match body %} + {% when RemoveDeviceBody::Form { device_card, last_device } %} + {{ device_card }} +

+ Do you want to remove this device from your account? It will be signed out immediately. +

+ {% if last_device %} +

+ This is your last device. 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. +

+ {% else %} +

+ This may be a destructive action. Make sure you have access to + another confirmed device, or your recovery key, to avoid losing access to your encrypted messages. +

+ {% endif %} +
+ +
+ {% when RemoveDeviceBody::Success %} +

+ The device has been signed out successfully. +

+ {% endmatch %} +
+{%- endblock -%} diff --git a/src/web/session/mod.rs b/src/web/session/mod.rs index 6694b826f..ab7a9ea4e 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}; +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}")