mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-05-26 20:49:55 +00:00
feat: Allow devices to be removed from the account panel
This commit is contained in:
@@ -7,10 +7,16 @@ use axum::{
|
|||||||
extract::State,
|
extract::State,
|
||||||
routing::method_routing::{get, post},
|
routing::method_routing::{get, post},
|
||||||
};
|
};
|
||||||
|
use const_str::concat;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
pub(crate) use server_metadata::*;
|
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> {
|
pub(crate) fn router() -> Router<crate::State> {
|
||||||
Router::new().nest(BASE_PATH, oauth_router())
|
Router::new().nest(BASE_PATH, oauth_router())
|
||||||
@@ -24,9 +30,9 @@ pub(crate) fn router() -> Router<crate::State> {
|
|||||||
|
|
||||||
fn oauth_router() -> Router<crate::State> {
|
fn oauth_router() -> Router<crate::State> {
|
||||||
Router::new()
|
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
|
// TODO(unspecced): used by old versions of the matrix-js-sdk
|
||||||
.route(JWKS_URI_PATH, get(async || Json(json!({"keys": []}))))
|
.route(concat!("/", JWKS_URI_PATH), get(async || Json(json!({"keys": []}))))
|
||||||
.route(TOKEN_PATH, post(token::token_route))
|
.route(concat!("/", TOKEN_PATH), post(token::token_route))
|
||||||
.route(TOKEN_REVOKE_PATH, post(token::revoke_token_route))
|
.route(concat!("/", TOKEN_REVOKE_PATH), post(token::revoke_token_route))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ use ruma::{api::client::discovery::get_authorization_server_metadata, serde::Raw
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use service::Services;
|
use service::Services;
|
||||||
|
|
||||||
use crate::Ruma;
|
use crate::{
|
||||||
|
Ruma,
|
||||||
pub(super) const AUTH_CODE_PATH: &str = "grant/authorization_code";
|
client::oauth::{
|
||||||
pub(super) const JWKS_URI_PATH: &str = "client/keys.json";
|
AUTH_CODE_PATH, CLIENT_REGISTER_PATH, JWKS_URI_PATH, TOKEN_PATH, TOKEN_REVOKE_PATH,
|
||||||
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";
|
|
||||||
|
|
||||||
pub(crate) async fn get_authorization_server_metadata_route(
|
pub(crate) async fn get_authorization_server_metadata_route(
|
||||||
State(services): State<crate::State>,
|
State(services): State<crate::State>,
|
||||||
|
|||||||
+3
-1
@@ -3,7 +3,7 @@ use std::{any::Any, sync::Once, time::Duration};
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::rejection::{FormRejection, QueryRejection},
|
extract::rejection::{FormRejection, PathRejection, QueryRejection},
|
||||||
http::{HeaderValue, StatusCode, header},
|
http::{HeaderValue, StatusCode, header},
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
@@ -37,6 +37,8 @@ enum WebError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
FormRejection(#[from] FormRejection),
|
FormRejection(#[from] FormRejection),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
|
PathRejection(#[from] PathRejection),
|
||||||
|
#[error("{0}")]
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
|
||||||
#[error("This page does not exist.")]
|
#[error("This page does not exist.")]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +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_service::threepid::EmailRequirement;
|
use conduwuit_service::threepid::EmailRequirement;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use ruma::{OwnedClientSecret, OwnedSessionId};
|
use ruma::{OwnedClientSecret, OwnedSessionId};
|
||||||
@@ -12,11 +13,12 @@ use crate::{
|
|||||||
template,
|
template,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod cross_signing_reset;
|
pub(crate) mod cross_signing_reset;
|
||||||
mod deactivate;
|
pub(crate) mod deactivate;
|
||||||
mod email;
|
pub(crate) mod device;
|
||||||
mod login;
|
pub(crate) mod email;
|
||||||
mod password;
|
pub(crate) mod login;
|
||||||
|
pub(crate) mod password;
|
||||||
|
|
||||||
pub(crate) fn build() -> Router<crate::State> {
|
pub(crate) fn build() -> Router<crate::State> {
|
||||||
#[allow(clippy::wildcard_imports)]
|
#[allow(clippy::wildcard_imports)]
|
||||||
@@ -29,6 +31,7 @@ pub(crate) fn build() -> Router<crate::State> {
|
|||||||
.nest("/email/", email::build())
|
.nest("/email/", email::build())
|
||||||
.nest("/cross_signing_reset", cross_signing_reset::build())
|
.nest("/cross_signing_reset", cross_signing_reset::build())
|
||||||
.nest("/deactivate", deactivate::build())
|
.nest("/deactivate", deactivate::build())
|
||||||
|
.nest("/device/", device::build())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
@@ -64,11 +67,24 @@ async fn get_account(
|
|||||||
let mut devices: Vec<_> = services
|
let mut devices: Vec<_> = services
|
||||||
.users
|
.users
|
||||||
.all_device_ids(&user_id)
|
.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()
|
.collect()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
devices.sort_unstable_by(|a, b| a.last_seen_ts.cmp(&b.last_seen_ts).reverse());
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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};
|
||||||
use ruma::{OwnedDeviceId, OwnedUserId, UserId};
|
use ruma::{OwnedDeviceId, OwnedUserId, UserId, api::client::device::Device};
|
||||||
|
|
||||||
pub(super) mod form;
|
pub(super) mod form;
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ pub(super) struct DeviceCard {
|
|||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub avatar: Avatar,
|
pub avatar: Avatar,
|
||||||
pub last_active: String,
|
pub last_active: String,
|
||||||
pub last_seen_ts: Option<u64>,
|
|
||||||
pub oauth_metadata: Option<ClientMetadata>,
|
pub oauth_metadata: Option<ClientMetadata>,
|
||||||
|
pub show_actions: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HtmlSafe for DeviceCard {}
|
impl HtmlSafe for DeviceCard {}
|
||||||
@@ -97,18 +97,13 @@ 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_id: OwnedDeviceId,
|
device: Device,
|
||||||
|
show_actions: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let device = services
|
|
||||||
.users
|
|
||||||
.get_device_metadata(user_id, &device_id)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let oauth_metadata = async {
|
let oauth_metadata = async {
|
||||||
let client_id = services
|
let client_id = services
|
||||||
.oauth
|
.oauth
|
||||||
.get_client_id_for_device(user_id, &device_id)
|
.get_client_id_for_device(user_id, &device.device_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Some(
|
Some(
|
||||||
@@ -124,11 +119,7 @@ impl DeviceCard {
|
|||||||
let display_name = oauth_metadata
|
let display_name = oauth_metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|metadata| metadata.client_name.clone())
|
.and_then(|metadata| metadata.client_name.clone())
|
||||||
.or_else(|| {
|
.or_else(|| device.display_name.clone());
|
||||||
device
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|device| device.display_name.clone())
|
|
||||||
});
|
|
||||||
|
|
||||||
let avatar = {
|
let avatar = {
|
||||||
let avatar_src = oauth_metadata
|
let avatar_src = oauth_metadata
|
||||||
@@ -153,9 +144,7 @@ impl DeviceCard {
|
|||||||
Avatar { avatar_type }
|
Avatar { avatar_type }
|
||||||
};
|
};
|
||||||
|
|
||||||
let last_seen_ts = device.as_ref().and_then(|device| device.last_seen_ts);
|
let last_active = device.last_seen_ts.map_or_else(
|
||||||
|
|
||||||
let last_active = last_seen_ts.map_or_else(
|
|
||||||
|| "unknown".to_owned(),
|
|| "unknown".to_owned(),
|
||||||
|last_seen_ts| {
|
|last_seen_ts| {
|
||||||
last_seen_ts
|
last_seen_ts
|
||||||
@@ -169,12 +158,12 @@ impl DeviceCard {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
device_id,
|
device_id: device.device_id,
|
||||||
display_name,
|
display_name,
|
||||||
avatar,
|
avatar,
|
||||||
last_active,
|
last_active,
|
||||||
last_seen_ts: last_seen_ts.map(|last_seen_ts| last_seen_ts.as_secs().into()),
|
|
||||||
oauth_metadata,
|
oauth_metadata,
|
||||||
|
show_actions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,10 @@
|
|||||||
<p>
|
<p>
|
||||||
Last active: {{ last_active }}
|
Last active: {{ last_active }}
|
||||||
</p>
|
</p>
|
||||||
|
{% if show_actions %}
|
||||||
|
<p>
|
||||||
|
<a href="device/{{ device_id }}/remove">Remove</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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 -%}
|
||||||
@@ -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};
|
use crate::{ROUTE_PREFIX, WebError, pages::account::device::RemoveDevicePath};
|
||||||
|
|
||||||
pub(crate) mod store;
|
pub(crate) mod store;
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ pub(crate) enum LoginTarget {
|
|||||||
ChangeEmail,
|
ChangeEmail,
|
||||||
CrossSigningReset,
|
CrossSigningReset,
|
||||||
Deactivate,
|
Deactivate,
|
||||||
|
RemoveDevice(RemoveDevicePath),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for LoginTarget {
|
impl PartialEq for LoginTarget {
|
||||||
@@ -51,6 +52,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) =>
|
||||||
|
format!("account/device/{}/remove", path.device,).into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
format!("{ROUTE_PREFIX}/{path}")
|
format!("{ROUTE_PREFIX}/{path}")
|
||||||
|
|||||||
Reference in New Issue
Block a user