deplace ui fediverse-ui
EBO <eric.bouhana@softalys.com> committé le 2026-05-14 00:18
48e62ebae69598838588a44d77126e503ddabc1b
1 parent(s)
63 fichiers modifiés
+6140
-4135
M
Cargo.lock
+25
-2
@@ -4130,6 +4130,7 @@ dependencies = [
| 4130 | 4130 | "dotenvy", |
| 4131 | 4131 | "rustwarden-core", |
| 4132 | 4132 | "rustwarden-fediverse", |
| 4133 | + "rustwarden-fediverse-ui", | |
| 4133 | 4134 | "rustwarden-ui", |
| 4134 | 4135 | "sea-orm", |
| 4135 | 4136 | "serde_json", |
@@ -4237,7 +4238,7 @@ dependencies = [
| 4237 | 4238 | ] |
| 4238 | 4239 | |
| 4239 | 4240 | [[package]] |
| 4240 | -name = "rustwarden-ui" | |
| 4241 | +name = "rustwarden-fediverse-ui" | |
| 4241 | 4242 | version = "0.1.0" |
| 4242 | 4243 | dependencies = [ |
| 4243 | 4244 | "askama", |
@@ -4246,9 +4247,31 @@ dependencies = [
| 4246 | 4247 | "axum-extra", |
| 4247 | 4248 | "chrono", |
| 4248 | 4249 | "futures", |
| 4249 | - "num-format", | |
| 4250 | 4250 | "rustwarden-core", |
| 4251 | 4251 | "rustwarden-fediverse", |
| 4252 | + "rustwarden-ui", | |
| 4253 | + "sea-orm", | |
| 4254 | + "serde", | |
| 4255 | + "serde_json", | |
| 4256 | + "time", | |
| 4257 | + "tokio", | |
| 4258 | + "tower-sessions", | |
| 4259 | + "tracing", | |
| 4260 | + "uuid", | |
| 4261 | + "validator", | |
| 4262 | +] | |
| 4263 | + | |
| 4264 | +[[package]] | |
| 4265 | +name = "rustwarden-ui" | |
| 4266 | +version = "0.1.0" | |
| 4267 | +dependencies = [ | |
| 4268 | + "askama", | |
| 4269 | + "async-trait", | |
| 4270 | + "axum", | |
| 4271 | + "axum-extra", | |
| 4272 | + "chrono", | |
| 4273 | + "num-format", | |
| 4274 | + "rustwarden-core", | |
| 4252 | 4275 | "sea-orm", |
| 4253 | 4276 | "serde", |
| 4254 | 4277 | "serde_json", |
M
Cargo.toml
+1
-0
@@ -2,6 +2,7 @@
| 2 | 2 | members = [ |
| 3 | 3 | "crates/rustwarden-core", |
| 4 | 4 | "crates/rustwarden-fediverse", |
| 5 | + "crates/rustwarden-fediverse-ui", | |
| 5 | 6 | "crates/rustwarden-ui", |
| 6 | 7 | "app", |
| 7 | 8 | "tools/rustwarden-cli", |
M
app/Cargo.toml
+10
-4
@@ -11,18 +11,24 @@ path = "src/main.rs"
| 11 | 11 | |
| 12 | 12 | [features] |
| 13 | 13 | default = [] |
| 14 | -# Active la fédération ActivityPub (RFC 0001+). | |
| 15 | -# Compile et lie le crate `rustwarden-fediverse` (services, routes, | |
| 16 | -# protocoles) et les pages UI HTML d'administration (rustwarden-ui). | |
| 14 | +# Active le protocole ActivityPub headless (RFC 0001+). | |
| 15 | +# Compile et lie la crate `rustwarden-fediverse` (services, routes, | |
| 16 | +# signatures HTTP) sans aucune UI. | |
| 17 | 17 | fediverse = [ |
| 18 | 18 | "dep:rustwarden-fediverse", |
| 19 | - "rustwarden-ui/fediverse", | |
| 19 | +] | |
| 20 | +# Active l'UI HTML de référence pour la fédération (handlers, templates, | |
| 21 | +# carte dashboard). Implique automatiquement `fediverse`. | |
| 22 | +fediverse-ui = [ | |
| 23 | + "fediverse", | |
| 24 | + "dep:rustwarden-fediverse-ui", | |
| 20 | 25 | ] |
| 21 | 26 | |
| 22 | 27 | [dependencies] |
| 23 | 28 | rustwarden-core = { path = "../crates/rustwarden-core", features = ["oauth"] } |
| 24 | 29 | rustwarden-ui = { path = "../crates/rustwarden-ui" } |
| 25 | 30 | rustwarden-fediverse = { path = "../crates/rustwarden-fediverse", optional = true } |
| 31 | +rustwarden-fediverse-ui = { path = "../crates/rustwarden-fediverse-ui", optional = true } | |
| 26 | 32 | sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } |
| 27 | 33 | axum = { version = "0.8", features = ["macros"] } |
| 28 | 34 | tokio = { version = "1", features = ["full"] } |
M
app/src/main.rs
+10
-0
@@ -40,5 +40,15 @@ async fn main() -> anyhow::Result<()> {
| 40 | 40 | .merge_routes(fediverse_router) |
| 41 | 41 | }; |
| 42 | 42 | |
| 43 | + // The reference UI for the federation lives in | |
| 44 | + // `rustwarden-fediverse-ui` and is opt-in via the `fediverse-ui` | |
| 45 | + // feature (which itself implies `fediverse`). When enabled, mount | |
| 46 | + // the UI router and inject the federation card into the admin | |
| 47 | + // dashboard via the builder hook. | |
| 48 | + #[cfg(feature = "fediverse-ui")] | |
| 49 | + let builder = builder | |
| 50 | + .merge_routes(rustwarden_fediverse_ui::default_routes()) | |
| 51 | + .add_dashboard_card(rustwarden_fediverse_ui::dashboard_card()); | |
| 52 | + | |
| 43 | 53 | builder.build().await?.run().await |
| 44 | 54 | } |
M
crates/rustwarden-core/src/app.rs
+27
-0
@@ -36,6 +36,7 @@ use crate::config::auth::AuthConfig;
| 36 | 36 | use crate::config::database; |
| 37 | 37 | use crate::config::email::EmailConfig; |
| 38 | 38 | use crate::config::server::ServerConfig; |
| 39 | +use crate::dashboard::DashboardCardDTO; | |
| 39 | 40 | use crate::hooks::{NoopHooks, RustwardenHooks}; |
| 40 | 41 | use crate::i18n::extractor::Locale; |
| 41 | 42 | use crate::i18n::loader::load_translations_from_directory; |
@@ -141,6 +142,12 @@ pub struct RustwardenBuilder {
| 141 | 142 | /// the consumer mounts its routes; left unset for headless apps. |
| 142 | 143 | pub(crate) revocation_checker: |
| 143 | 144 | Option<Arc<dyn crate::middleware::revocation::RemoteIdentityRevocationChecker>>, |
| 145 | + /// Extra cards rendered at the bottom of the admin dashboard. | |
| 146 | + /// Filled by companion crates (e.g. `rustwarden-fediverse-ui`) via | |
| 147 | + /// [`RustwardenBuilder::add_dashboard_card`]; injected as an Axum | |
| 148 | + /// `Extension<Vec<DashboardCardDTO>>` so `admin_dashboard` can pick | |
| 149 | + /// them up at render time. | |
| 150 | + pub(crate) dashboard_cards: Vec<DashboardCardDTO>, | |
| 144 | 151 | } |
| 145 | 152 | |
| 146 | 153 | impl Default for RustwardenBuilder { |
@@ -166,6 +173,7 @@ impl Default for RustwardenBuilder {
| 166 | 173 | "/admin".into(), |
| 167 | 174 | ], |
| 168 | 175 | revocation_checker: None, |
| 176 | + dashboard_cards: Vec::new(), | |
| 169 | 177 | } |
| 170 | 178 | } |
| 171 | 179 | } |
@@ -257,6 +265,19 @@ impl RustwardenBuilder {
| 257 | 265 | self |
| 258 | 266 | } |
| 259 | 267 | |
| 268 | + /// Append a card to the admin dashboard. | |
| 269 | + /// | |
| 270 | + /// The card is exposed to handlers as part of an Axum | |
| 271 | + /// `Extension<Vec<DashboardCardDTO>>` and rendered by | |
| 272 | + /// `admin_dashboard` after the built-in cards. Companion crates | |
| 273 | + /// (e.g. `rustwarden-fediverse-ui`) ship a constructor returning a | |
| 274 | + /// pre-filled DTO so the consumer only has to pipe it into the | |
| 275 | + /// builder. | |
| 276 | + pub fn add_dashboard_card(mut self, card: DashboardCardDTO) -> Self { | |
| 277 | + self.dashboard_cards.push(card); | |
| 278 | + self | |
| 279 | + } | |
| 280 | + | |
| 260 | 281 | /// Enable automatic database migration at startup. |
| 261 | 282 | pub fn auto_migrate(mut self, enabled: bool) -> Self { |
| 262 | 283 | self.auto_migrate = enabled; |
@@ -533,6 +554,7 @@ impl RustwardenBuilder {
| 533 | 554 | let protected_prefixes = std::sync::Arc::new(self.protected_prefixes.clone()); |
| 534 | 555 | let prefixes_for_auth = std::sync::Arc::clone(&protected_prefixes); |
| 535 | 556 | let revocation_checker = self.revocation_checker.clone(); |
| 557 | + let dashboard_cards = self.dashboard_cards.clone(); | |
| 536 | 558 | |
| 537 | 559 | let app = router.with_state(db).layer( |
| 538 | 560 | ServiceBuilder::new() |
@@ -565,6 +587,11 @@ impl RustwardenBuilder {
| 565 | 587 | app |
| 566 | 588 | }; |
| 567 | 589 | |
| 590 | + // Expose the configured dashboard cards as an Axum extension so | |
| 591 | + // `admin_dashboard` can render them. Always inserted (even when | |
| 592 | + // the list is empty) so the handler signature stays uniform. | |
| 593 | + let app = app.layer(axum::Extension(dashboard_cards)); | |
| 594 | + | |
| 568 | 595 | #[cfg(feature = "oauth")] |
| 569 | 596 | let app = app.layer(axum::Extension(oauth_registry)); |
| 570 | 597 |
A
crates/rustwarden-core/src/dashboard.rs
+28
-0
@@ -0,0 +1,28 @@
| 1 | +//! Dashboard extension surface used by companion crates. | |
| 2 | +//! | |
| 3 | +//! The admin dashboard rendered by `rustwarden-ui` accepts a list of | |
| 4 | +//! [`DashboardCardDTO`] entries injected via Axum `Extension`. Companion | |
| 5 | +//! crates (e.g. `rustwarden-fediverse-ui`) build a card and the | |
| 6 | +//! consumer application registers it through | |
| 7 | +//! [`crate::app::RustwardenBuilder::add_dashboard_card`]. Living in | |
| 8 | +//! `rustwarden-core` keeps the type available to the builder without | |
| 9 | +//! creating a `rustwarden-core → rustwarden-ui` cycle. | |
| 10 | + | |
| 11 | +use serde::Serialize; | |
| 12 | + | |
| 13 | +/// A single card displayed at the bottom of the admin dashboard. | |
| 14 | +/// | |
| 15 | +/// The two `*_key` fields are i18n lookup keys resolved at render time | |
| 16 | +/// against the active locale, so a card stays correctly localized | |
| 17 | +/// without requiring callers to pre-translate. | |
| 18 | +#[derive(Clone, Debug, Serialize)] | |
| 19 | +pub struct DashboardCardDTO { | |
| 20 | + /// i18n key for the card title (resolved at render time). | |
| 21 | + pub title_key: String, | |
| 22 | + /// i18n key for the card description (resolved at render time). | |
| 23 | + pub desc_key: String, | |
| 24 | + /// Target URL the card links to. | |
| 25 | + pub href: String, | |
| 26 | + /// Display icon (emoji, heroicon name or daisyui token). | |
| 27 | + pub icon: String, | |
| 28 | +} |
M
crates/rustwarden-core/src/lib.rs
+3
-0
@@ -7,6 +7,9 @@
| 7 | 7 | pub mod app; |
| 8 | 8 | /// Authentication extractors, JWT service, and session management. |
| 9 | 9 | pub mod auth; |
| 10 | +/// Dashboard extension surface (`DashboardCardDTO`) used by companion | |
| 11 | +/// crates to inject extra cards into the admin dashboard. | |
| 12 | +pub mod dashboard; | |
| 10 | 13 | /// Server, database, authentication, and email configuration. |
| 11 | 14 | pub mod config; |
| 12 | 15 | /// Application error types and HTTP response conversion. |
A
crates/rustwarden-fediverse-ui/Cargo.toml
+29
-0
@@ -0,0 +1,29 @@
| 1 | +[package] | |
| 2 | +name = "rustwarden-fediverse-ui" | |
| 3 | +version = "0.1.0" | |
| 4 | +edition.workspace = true | |
| 5 | +rust-version.workspace = true | |
| 6 | +description = "Reference HTML UI for Rustwarden ActivityPub federation — handlers, templates, dashboard card" | |
| 7 | + | |
| 8 | +[dependencies] | |
| 9 | +rustwarden-core = { path = "../rustwarden-core" } | |
| 10 | +rustwarden-fediverse = { path = "../rustwarden-fediverse" } | |
| 11 | +rustwarden-ui = { path = "../rustwarden-ui" } | |
| 12 | +axum = { version = "0.8", features = ["macros"] } | |
| 13 | +tokio = { version = "1", features = ["full"] } | |
| 14 | +async-trait = "0.1" | |
| 15 | +futures = "0.3" | |
| 16 | +serde = { version = "1.0", features = ["derive"] } | |
| 17 | +serde_json = "1.0" | |
| 18 | +askama = "0.15" | |
| 19 | +tracing = "0.1" | |
| 20 | +sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } | |
| 21 | +uuid = { version = "1.0", features = ["v4", "serde"] } | |
| 22 | +chrono = { version = "0.4", features = ["serde"] } | |
| 23 | +tower-sessions = "0.14" | |
| 24 | +axum-extra = { version = "0.12", features = ["cookie"] } | |
| 25 | +validator = { version = "0.20", features = ["derive"] } | |
| 26 | +time = { version = "0.3", features = ["macros", "serde"] } | |
| 27 | + | |
| 28 | +[lints] | |
| 29 | +workspace = true |
A
crates/rustwarden-fediverse-ui/askama.toml
+5
-0
@@ -0,0 +1,5 @@
| 1 | +[general] | |
| 2 | +dirs = [ | |
| 3 | + "templates", | |
| 4 | + "../rustwarden-ui/templates", | |
| 5 | +] |
A
crates/rustwarden-fediverse-ui/examples/templates/README.md
+30
-0
@@ -0,0 +1,30 @@
| 1 | +# Reference templates | |
| 2 | + | |
| 3 | +The Askama templates shipped under `crates/rustwarden-fediverse-ui/templates/` | |
| 4 | +are exposed at the source level via the [`template_examples`](../../src/template_examples.rs) | |
| 5 | +module. Every constant is a `&'static str` produced by `include_str!`, so a | |
| 6 | +project re-implementing its own rendering layer can copy the source verbatim | |
| 7 | +and adapt it to a different engine (Tera, Maud, Leptos…). | |
| 8 | + | |
| 9 | +## How to consume | |
| 10 | + | |
| 11 | +```rust | |
| 12 | +use rustwarden_fediverse_ui::template_examples; | |
| 13 | + | |
| 14 | +fn main() { | |
| 15 | + println!("{}", template_examples::ADMIN_SETTINGS); | |
| 16 | +} | |
| 17 | +``` | |
| 18 | + | |
| 19 | +## Structure | |
| 20 | + | |
| 21 | +- Public templates (login flow): `login_form.html`, `login_waiting.html`, | |
| 22 | + `error_unauthorized.html`, `consent_screen.html`, `invite_landing.html`. | |
| 23 | +- User-profile template: `connected_apps.html`. | |
| 24 | +- Admin templates under `admin/`: `settings.html`, `trust_list.html`, | |
| 25 | + `queue.html`, `sessions_remote.html`, `nodeinfo.html`, `team_invites.html`. | |
| 26 | + | |
| 27 | +All templates rely on i18n keys served by `rustwarden-core`'s `I18nService` | |
| 28 | +and on the shared `base.html`/`components/navbar.html` exposed by | |
| 29 | +`rustwarden-ui` — re-using them inside another project requires either | |
| 30 | +keeping the same base layout or adapting the `{% extends %}` directive. |
A
crates/rustwarden-fediverse-ui/src/dashboard.rs
+26
-0
@@ -0,0 +1,26 @@
| 1 | +//! Dashboard card factory for the admin dashboard injection point. | |
| 2 | +//! | |
| 3 | +//! Returns a [`DashboardCardDTO`] that the application merges into the | |
| 4 | +//! admin dashboard via `RustwardenBuilder::add_dashboard_card`. Keeps | |
| 5 | +//! the federation card self-described so `rustwarden-ui` no longer | |
| 6 | +//! needs to know federation exists. | |
| 7 | + | |
| 8 | +use rustwarden_core::dashboard::DashboardCardDTO; | |
| 9 | + | |
| 10 | +/// Returns the federation card to inject into the admin dashboard. | |
| 11 | +/// | |
| 12 | +/// The `title_key` and `desc_key` fields point at i18n keys resolved | |
| 13 | +/// at render time (`admin.dashboard.fediverse_card_title` and | |
| 14 | +/// `admin.dashboard.fediverse_card_desc`), so the card stays localized | |
| 15 | +/// without coupling this crate to a specific locale. The `href` lands | |
| 16 | +/// on the federation hub (`/admin/fediverse`) which presents the six | |
| 17 | +/// admin sub-sections; previous direct deep-link to `settings` was | |
| 18 | +/// replaced when the hub landed. | |
| 19 | +pub fn dashboard_card() -> DashboardCardDTO { | |
| 20 | + DashboardCardDTO { | |
| 21 | + title_key: "admin.dashboard.fediverse_card_title".into(), | |
| 22 | + desc_key: "admin.dashboard.fediverse_card_desc".into(), | |
| 23 | + href: "/admin/fediverse".into(), | |
| 24 | + icon: "🌐".into(), | |
| 25 | + } | |
| 26 | +} |
A
crates/rustwarden-fediverse-ui/src/dto/admin_tabs.rs
+78
-0
@@ -0,0 +1,78 @@
| 1 | +//! Tab navigation DTO shared by every `/admin/fediverse/*` screen. | |
| 2 | +//! | |
| 3 | +//! Exposes [`AdminTabDTO`] (one entry rendered into the tab bar) and the | |
| 4 | +//! [`admin_tabs`] async factory that builds the six tabs with labels | |
| 5 | +//! resolved against the i18n service. Each handler then passes the | |
| 6 | +//! `tabs` vector to its template plus an `active_tab` slug so the | |
| 7 | +//! current entry gets the `tab-active` class. | |
| 8 | + | |
| 9 | +use rustwarden_core::i18n::service::I18nService; | |
| 10 | + | |
| 11 | +/// One entry rendered into the federation admin tab bar. | |
| 12 | +/// | |
| 13 | +/// `slug` is the stable identifier matched against the active page; the | |
| 14 | +/// template adds `tab-active` to the entry whose slug equals | |
| 15 | +/// `active_tab`. `label` carries the already-translated text (no key | |
| 16 | +/// resolution at template render time). | |
| 17 | +#[derive(Clone, serde::Serialize)] | |
| 18 | +pub struct AdminTabDTO { | |
| 19 | + /// Translated label rendered as the tab text. | |
| 20 | + pub label: String, | |
| 21 | + /// Absolute URL the tab links to. | |
| 22 | + pub href: String, | |
| 23 | + /// Logical identifier matched against `active_tab` to flag the | |
| 24 | + /// currently displayed tab. Stable across translations. | |
| 25 | + pub slug: String, | |
| 26 | +} | |
| 27 | + | |
| 28 | +/// Build the six federation admin tabs with localized labels. | |
| 29 | +/// | |
| 30 | +/// The order mirrors the ANSSI / RFC 0021 visual hierarchy: settings → | |
| 31 | +/// trust list → queue → remote sessions → nodeinfo → invites. The | |
| 32 | +/// invites tab points at the federation hub (`/admin/fediverse`) | |
| 33 | +/// because invitations are scoped per team — the admin picks the team | |
| 34 | +/// from the hub before reaching the per-team invites screen. | |
| 35 | +pub async fn admin_tabs(i18n: &I18nService, locale: &str) -> Vec<AdminTabDTO> { | |
| 36 | + let entries: &[(&str, &str, &str)] = &[ | |
| 37 | + ( | |
| 38 | + "settings", | |
| 39 | + "/admin/fediverse/settings", | |
| 40 | + "fediverse.admin.tabs.settings", | |
| 41 | + ), | |
| 42 | + ( | |
| 43 | + "trust_list", | |
| 44 | + "/admin/fediverse/trust_list", | |
| 45 | + "fediverse.admin.tabs.trust_list", | |
| 46 | + ), | |
| 47 | + ( | |
| 48 | + "queue", | |
| 49 | + "/admin/fediverse/queue", | |
| 50 | + "fediverse.admin.tabs.queue", | |
| 51 | + ), | |
| 52 | + ( | |
| 53 | + "sessions_remote", | |
| 54 | + "/admin/fediverse/sessions_remote", | |
| 55 | + "fediverse.admin.tabs.sessions_remote", | |
| 56 | + ), | |
| 57 | + ( | |
| 58 | + "nodeinfo", | |
| 59 | + "/admin/fediverse/nodeinfo", | |
| 60 | + "fediverse.admin.tabs.nodeinfo", | |
| 61 | + ), | |
| 62 | + ( | |
| 63 | + "invites", | |
| 64 | + "/admin/fediverse", | |
| 65 | + "fediverse.admin.tabs.invites", | |
| 66 | + ), | |
| 67 | + ]; | |
| 68 | + | |
| 69 | + let mut tabs = Vec::with_capacity(entries.len()); | |
| 70 | + for (slug, href, key) in entries { | |
| 71 | + tabs.push(AdminTabDTO { | |
| 72 | + label: i18n.translate(key, locale).await, | |
| 73 | + href: (*href).to_string(), | |
| 74 | + slug: (*slug).to_string(), | |
| 75 | + }); | |
| 76 | + } | |
| 77 | + tabs | |
| 78 | +} |
A
crates/rustwarden-fediverse-ui/src/dto/mod.rs
+6
-0
@@ -0,0 +1,6 @@
| 1 | +//! DTOs shared across the federation UI templates. | |
| 2 | + | |
| 3 | +/// Tab navigation entries for the `/admin/fediverse/*` screens. | |
| 4 | +pub mod admin_tabs; | |
| 5 | + | |
| 6 | +pub use admin_tabs::{admin_tabs, AdminTabDTO}; |
A
crates/rustwarden-fediverse-ui/src/handlers/admin.rs
+2018
-0
@@ -0,0 +1,2018 @@
| 1 | +//! Admin handlers for the Fediverse UI foundations slice (RFC 0021). | |
| 2 | +//! | |
| 3 | +//! Ships two endpoints behind the standard `admin_required` middleware: | |
| 4 | +//! | |
| 5 | +//! - `GET /admin/fediverse/settings` renders a form pre-filled with the | |
| 6 | +//! four runtime toggles persisted in `app_settings`: | |
| 7 | +//! `fediverse_enabled`, `fediverse_trust_mode`, | |
| 8 | +//! `fediverse_interop_scope`, `fediverse_authorized_fetch`. | |
| 9 | +//! - `POST /admin/fediverse/settings` applies the form (CSRF-checked) | |
| 10 | +//! and redirects back to the GET handler with a flash-style toast | |
| 11 | +//! parameter. | |
| 12 | +//! | |
| 13 | +//! All labels are pulled from `fediverse.*` keys in `locales/*.yml`. | |
| 14 | +//! The trust-list screen is delivered in a follow-up patch within the | |
| 15 | +//! same RFC 0021 slice. | |
| 16 | + | |
| 17 | +use std::sync::Arc; | |
| 18 | + | |
| 19 | +use askama::Template; | |
| 20 | +use axum::extract::{Extension, Form, State}; | |
| 21 | +use axum::response::{IntoResponse, Redirect}; | |
| 22 | +use sea_orm::DatabaseConnection; | |
| 23 | +use serde::Deserialize; | |
| 24 | +use tower_sessions::Session; | |
| 25 | + | |
| 26 | +use rustwarden_core::auth::jwt::Claims; | |
| 27 | +use rustwarden_core::error::AppError; | |
| 28 | +use rustwarden_core::i18n::extractor::Locale; | |
| 29 | +use rustwarden_core::i18n::service::I18nService; | |
| 30 | +use rustwarden_core::middleware::csrf; | |
| 31 | +use rustwarden_core::services::app_settings_service::AppSettingsService; | |
| 32 | +use rustwarden_fediverse::services::fediverse_trust_service::{ | |
| 33 | + BlockSeverity, FediverseTrustService, TrustMode, | |
| 34 | +}; | |
| 35 | + | |
| 36 | +use rustwarden_fediverse::models::fediverse_outbound_queue; | |
| 37 | +use rustwarden_fediverse::models::remote_identity; | |
| 38 | +use rustwarden_core::models::user as user_model; | |
| 39 | +use rustwarden_fediverse::models::fediverse_pending_invite; | |
| 40 | +use rustwarden_core::models::team; | |
| 41 | +use rustwarden_fediverse::services::fediverse_invite_service::{ | |
| 42 | + FediverseInviteService, InviteStatus, | |
| 43 | +}; | |
| 44 | +use rustwarden_core::services::team_service::TeamRole; | |
| 45 | +use rustwarden_fediverse::services::fediverse_outbound_service::OutboundStatus; | |
| 46 | +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect}; | |
| 47 | + | |
| 48 | +use rustwarden_ui::dto::NavigationDTO; | |
| 49 | +use rustwarden_ui::template_helpers::build_base_context_with_claims; | |
| 50 | + | |
| 51 | +use crate::dto::{admin_tabs, AdminTabDTO}; | |
| 52 | + | |
| 53 | +/// Navbar entry consumed by `components/navigation.html` (admin pages). | |
| 54 | +/// | |
| 55 | +/// Mirrors the private struct used in `admin_handler` so this file | |
| 56 | +/// stays standalone — duplication is intentional until a shared | |
| 57 | +/// `AdminLayoutContext` lands (deferred to a generic refactor). | |
| 58 | +#[derive(Debug, Clone)] | |
| 59 | +#[allow(dead_code)] | |
| 60 | +pub struct NavItem { | |
| 61 | + /// Stable identifier; matched against the active page id. | |
| 62 | + pub id: String, | |
| 63 | + /// Visible label (already i18n-translated). | |
| 64 | + pub label: String, | |
| 65 | + /// CSS / heroicon name. | |
| 66 | + pub icon: String, | |
| 67 | + /// Absolute URL the entry points to. | |
| 68 | + pub url: String, | |
| 69 | + /// `true` when this entry corresponds to the current page. | |
| 70 | + pub active: bool, | |
| 71 | + /// `true` when the entry is clickable. | |
| 72 | + pub enabled: bool, | |
| 73 | + /// Badge text (empty string for none). | |
| 74 | + pub badge: String, | |
| 75 | + /// Whether to render the badge. | |
| 76 | + pub has_badge: bool, | |
| 77 | + /// Tooltip shown when `enabled` is false. | |
| 78 | + pub disabled_message: String, | |
| 79 | + /// Whether to render the disabled-tooltip. | |
| 80 | + pub has_disabled_message: bool, | |
| 81 | +} | |
| 82 | + | |
| 83 | +async fn build_fediverse_admin_nav( | |
| 84 | + roles: &[String], | |
| 85 | + i18n_service: &Arc<I18nService>, | |
| 86 | + locale: &Locale, | |
| 87 | + db: &DatabaseConnection, | |
| 88 | +) -> Vec<NavItem> { | |
| 89 | + let navigation = NavigationDTO::for_user(roles, i18n_service, locale, Some(db)).await; | |
| 90 | + navigation | |
| 91 | + .items | |
| 92 | + .iter() | |
| 93 | + .map(|item| NavItem { | |
| 94 | + id: item.id.clone(), | |
| 95 | + label: item.label.clone(), | |
| 96 | + icon: item.icon.clone(), | |
| 97 | + url: item.url.clone(), | |
| 98 | + active: item.id == "admin", | |
| 99 | + enabled: item.enabled, | |
| 100 | + badge: item.badge.clone(), | |
| 101 | + has_badge: item.has_badge, | |
| 102 | + disabled_message: item.disabled_message.clone(), | |
| 103 | + has_disabled_message: item.has_disabled_message, | |
| 104 | + }) | |
| 105 | + .collect() | |
| 106 | +} | |
| 107 | + | |
| 108 | +/// Form payload for `POST /admin/fediverse/settings`. | |
| 109 | +/// | |
| 110 | +/// Checkboxes are absent from the body when unchecked, hence the | |
| 111 | +/// `Option<String>` pattern + presence test rather than `bool`. | |
| 112 | +#[derive(Debug, Deserialize)] | |
| 113 | +pub struct UpdateFediverseSettingsForm { | |
| 114 | + /// CSRF token from the hidden form field. | |
| 115 | + pub csrf_token: String, | |
| 116 | + /// Present only when the toggle is checked. | |
| 117 | + #[serde(default)] | |
| 118 | + pub fediverse_enabled: Option<String>, | |
| 119 | + /// Selector value: `allowlist` or `open_with_blocklist`. | |
| 120 | + pub fediverse_trust_mode: String, | |
| 121 | + /// Selector value: `rustwarden_only`, `rustwarden_plus_universal_read`, | |
| 122 | + /// or `open`. | |
| 123 | + pub fediverse_interop_scope: String, | |
| 124 | + /// Present only when the toggle is checked. | |
| 125 | + #[serde(default)] | |
| 126 | + pub fediverse_authorized_fetch: Option<String>, | |
| 127 | +} | |
| 128 | + | |
| 129 | +/// Subset of `app_settings` rendered into the settings template. | |
| 130 | +#[derive(Debug, Clone)] | |
| 131 | +pub struct FediverseSettingsCtx { | |
| 132 | + /// `"true"` when ActivityPub federation is active runtime-wide. | |
| 133 | + pub fediverse_enabled: String, | |
| 134 | + /// `allowlist` (default) or `open_with_blocklist`. | |
| 135 | + pub fediverse_trust_mode: String, | |
| 136 | + /// Interop scope selector value. | |
| 137 | + pub fediverse_interop_scope: String, | |
| 138 | + /// `"true"` when authorized-fetch (RFC 0017) is required. | |
| 139 | + pub fediverse_authorized_fetch: String, | |
| 140 | +} | |
| 141 | + | |
| 142 | +/// Askama template for `GET /admin/fediverse/settings`. | |
| 143 | +#[derive(Template)] | |
| 144 | +#[template(path = "admin/settings.html")] | |
| 145 | +#[allow(dead_code)] | |
| 146 | +pub struct FediverseSettingsTemplate { | |
| 147 | + /// Browser tab + page title. | |
| 148 | + pub page_title: String, | |
| 149 | + /// CSRF token rendered as hidden input. | |
| 150 | + pub csrf_token: String, | |
| 151 | + /// Application display name. | |
| 152 | + pub app_name: String, | |
| 153 | + /// Application version (footer). | |
| 154 | + pub app_version: String, | |
| 155 | + /// Application domain (header). | |
| 156 | + pub app_domain: String, | |
| 157 | + /// Short app description (header). | |
| 158 | + pub app_description: String, | |
| 159 | + /// Username for the navbar avatar. | |
| 160 | + pub username: String, | |
| 161 | + /// Email for the navbar tooltip. | |
| 162 | + pub email: String, | |
| 163 | + /// Two-letter avatar initials. | |
| 164 | + pub initials: String, | |
| 165 | + /// CSS class for avatar background. | |
| 166 | + pub avatar_color: String, | |
| 167 | + /// Comma-joined roles for the navbar tooltip. | |
| 168 | + pub roles_text: String, | |
| 169 | + /// Whether the user has a pending password change banner. | |
| 170 | + pub has_pending_password_change: bool, | |
| 171 | + /// Navigation items for the navbar (built from `NavigationDTO`). | |
| 172 | + pub nav_items: Vec<NavItem>, | |
| 173 | + /// User menu — profile entry label. | |
| 174 | + pub user_menu_profile: String, | |
| 175 | + /// User menu — logout entry label. | |
| 176 | + pub user_menu_logout: String, | |
| 177 | + /// Banner displayed when `?saved=1` is in the URL. | |
| 178 | + pub saved_flash: bool, | |
| 179 | + /// Pre-filled form values. | |
| 180 | + pub settings: FediverseSettingsCtx, | |
| 181 | + | |
| 182 | + /// Heading "Réglages fédération". | |
| 183 | + pub heading: String, | |
| 184 | + /// Subtitle explaining the page. | |
| 185 | + pub subtitle: String, | |
| 186 | + /// Card title "Activation". | |
| 187 | + pub card_activation: String, | |
| 188 | + /// Card title "Confiance". | |
| 189 | + pub card_trust: String, | |
| 190 | + /// Card title "Interopérabilité". | |
| 191 | + pub card_interop: String, | |
| 192 | + /// Card title "Sécurité avancée". | |
| 193 | + pub card_security: String, | |
| 194 | + /// Toggle label. | |
| 195 | + pub enabled_label: String, | |
| 196 | + /// Toggle help text. | |
| 197 | + pub enabled_desc: String, | |
| 198 | + /// trust_mode label. | |
| 199 | + pub trust_mode_label: String, | |
| 200 | + /// trust_mode option: allowlist. | |
| 201 | + pub trust_mode_allowlist: String, | |
| 202 | + /// trust_mode option: open_with_blocklist. | |
| 203 | + pub trust_mode_open: String, | |
| 204 | + /// interop_scope label. | |
| 205 | + pub interop_scope_label: String, | |
| 206 | + /// interop_scope option: rustwarden_only. | |
| 207 | + pub interop_scope_rustwarden_only: String, | |
| 208 | + /// interop_scope option: rustwarden_plus_universal_read. | |
| 209 | + pub interop_scope_rustwarden_plus: String, | |
| 210 | + /// interop_scope option: open. | |
| 211 | + pub interop_scope_open: String, | |
| 212 | + /// authorized_fetch label. | |
| 213 | + pub authorized_fetch_label: String, | |
| 214 | + /// authorized_fetch help text. | |
| 215 | + pub authorized_fetch_desc: String, | |
| 216 | + /// Submit button label. | |
| 217 | + pub save_button: String, | |
| 218 | + /// Toast text shown after a successful save. | |
| 219 | + pub saved_toast: String, | |
| 220 | + /// Breadcrumb segment: home. | |
| 221 | + pub breadcrumb_home: String, | |
| 222 | + /// Breadcrumb segment: admin parent. | |
| 223 | + pub breadcrumb_admin: String, | |
| 224 | + /// Breadcrumb segment: federation hub (parent of every admin tab). | |
| 225 | + pub breadcrumb_federation: String, | |
| 226 | + /// Breadcrumb segment: current page (Federation). | |
| 227 | + pub breadcrumb_self: String, | |
| 228 | + /// Federation admin tab bar entries. | |
| 229 | + pub tabs: Vec<AdminTabDTO>, | |
| 230 | + /// Slug of the currently displayed tab; matches `tabs[i].slug`. | |
| 231 | + pub active_tab: String, | |
| 232 | +} | |
| 233 | + | |
| 234 | +rustwarden_core::impl_template_response!(FediverseSettingsTemplate); | |
| 235 | + | |
| 236 | +/// Allowed values for `fediverse_trust_mode` (RFC 0004). | |
| 237 | +const TRUST_MODES: &[&str] = &["allowlist", "open_with_blocklist"]; | |
| 238 | +/// Allowed values for `fediverse_interop_scope` (master plan §2.1). | |
| 239 | +const INTEROP_SCOPES: &[&str] = &[ | |
| 240 | + "rustwarden_only", | |
| 241 | + "rustwarden_plus_universal_read", | |
| 242 | + "open", | |
| 243 | +]; | |
| 244 | + | |
| 245 | +/// Render `GET /admin/fediverse/settings`. Pre-fills the form with the | |
| 246 | +/// current `app_settings` rows and shows a one-shot toast when the | |
| 247 | +/// referrer included `?saved=1`. | |
| 248 | +pub async fn get_settings( | |
| 249 | + Extension(claims): Extension<Claims>, | |
| 250 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 251 | + Extension(locale): Extension<Locale>, | |
| 252 | + State(db): State<DatabaseConnection>, | |
| 253 | + session: Session, | |
| 254 | + axum::extract::Query(query): axum::extract::Query<SavedQuery>, | |
| 255 | +) -> Result<impl IntoResponse, AppError> { | |
| 256 | + let locale_str = locale.as_str(); | |
| 257 | + let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 258 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 259 | + | |
| 260 | + let all_settings = AppSettingsService::get_all_settings(&db).await?; | |
| 261 | + let get_val = |key: &str, default: &str| -> String { | |
| 262 | + all_settings | |
| 263 | + .iter() | |
| 264 | + .find(|s| s.key == key) | |
| 265 | + .map(|s| s.value.clone()) | |
| 266 | + .unwrap_or_else(|| default.to_string()) | |
| 267 | + }; | |
| 268 | + | |
| 269 | + let settings_ctx = FediverseSettingsCtx { | |
| 270 | + fediverse_enabled: get_val("fediverse_enabled", "false"), | |
| 271 | + fediverse_trust_mode: get_val("fediverse_trust_mode", "allowlist"), | |
| 272 | + fediverse_interop_scope: get_val("fediverse_interop_scope", "rustwarden_only"), | |
| 273 | + fediverse_authorized_fetch: get_val("fediverse_authorized_fetch", "false"), | |
| 274 | + }; | |
| 275 | + | |
| 276 | + let t = |k: &str| { | |
| 277 | + let i18n = i18n_service.clone(); | |
| 278 | + let key = k.to_string(); | |
| 279 | + let loc = locale_str.to_string(); | |
| 280 | + async move { i18n.translate(&key, &loc).await } | |
| 281 | + }; | |
| 282 | + | |
| 283 | + let template = FediverseSettingsTemplate { | |
| 284 | + page_title: t("fediverse.settings.title").await, | |
| 285 | + csrf_token, | |
| 286 | + app_name: base_context.app_name, | |
| 287 | + app_version: base_context.app_version, | |
| 288 | + app_domain: base_context.app_domain, | |
| 289 | + app_description: base_context.app_description, | |
| 290 | + username: base_context.username, | |
| 291 | + email: base_context.email, | |
| 292 | + initials: base_context.initials, | |
| 293 | + avatar_color: base_context.avatar_color, | |
| 294 | + roles_text: base_context.roles.join(", "), | |
| 295 | + has_pending_password_change: false, | |
| 296 | + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 297 | + .await, | |
| 298 | + user_menu_profile: t("user_menu.profile").await, | |
| 299 | + user_menu_logout: t("user_menu.logout").await, | |
| 300 | + saved_flash: query.saved.unwrap_or(0) == 1, | |
| 301 | + settings: settings_ctx, | |
| 302 | + heading: t("fediverse.settings.heading").await, | |
| 303 | + subtitle: t("fediverse.settings.subtitle").await, | |
| 304 | + card_activation: t("fediverse.settings.card_activation").await, | |
| 305 | + card_trust: t("fediverse.settings.card_trust").await, | |
| 306 | + card_interop: t("fediverse.settings.card_interop").await, | |
| 307 | + card_security: t("fediverse.settings.card_security").await, | |
| 308 | + enabled_label: t("fediverse.settings.enabled_label").await, | |
| 309 | + enabled_desc: t("fediverse.settings.enabled_desc").await, | |
| 310 | + trust_mode_label: t("fediverse.settings.trust_mode_label").await, | |
| 311 | + trust_mode_allowlist: t("fediverse.settings.trust_mode_allowlist").await, | |
| 312 | + trust_mode_open: t("fediverse.settings.trust_mode_open").await, | |
| 313 | + interop_scope_label: t("fediverse.settings.interop_scope_label").await, | |
| 314 | + interop_scope_rustwarden_only: t("fediverse.settings.interop_scope_rustwarden_only") | |
| 315 | + .await, | |
| 316 | + interop_scope_rustwarden_plus: t("fediverse.settings.interop_scope_rustwarden_plus") | |
| 317 | + .await, | |
| 318 | + interop_scope_open: t("fediverse.settings.interop_scope_open").await, | |
| 319 | + authorized_fetch_label: t("fediverse.settings.authorized_fetch_label").await, | |
| 320 | + authorized_fetch_desc: t("fediverse.settings.authorized_fetch_desc").await, | |
| 321 | + save_button: t("fediverse.settings.save_button").await, | |
| 322 | + saved_toast: t("fediverse.settings.saved_toast").await, | |
| 323 | + breadcrumb_home: t("breadcrumb.home").await, | |
| 324 | + breadcrumb_admin: t("breadcrumb.admin").await, | |
| 325 | + breadcrumb_federation: t("fediverse.admin.hub.title").await, | |
| 326 | + breadcrumb_self: t("fediverse.settings.breadcrumb").await, | |
| 327 | + tabs: admin_tabs(&i18n_service, locale_str).await, | |
| 328 | + active_tab: "settings".to_string(), | |
| 329 | + }; | |
| 330 | + | |
| 331 | + Ok(template) | |
| 332 | +} | |
| 333 | + | |
| 334 | +/// Optional `?saved=1` query parameter. | |
| 335 | +#[derive(Debug, Deserialize)] | |
| 336 | +pub struct SavedQuery { | |
| 337 | + /// `1` after a successful POST → triggers the success toast. | |
| 338 | + pub saved: Option<u8>, | |
| 339 | +} | |
| 340 | + | |
| 341 | +/// Apply `POST /admin/fediverse/settings`. Validates CSRF + selector | |
| 342 | +/// values, persists 4 keys via [`AppSettingsService`], and redirects | |
| 343 | +/// back to the GET handler with `?saved=1`. | |
| 344 | +pub async fn update_settings( | |
| 345 | + Extension(claims): Extension<Claims>, | |
| 346 | + session: Session, | |
| 347 | + State(db): State<DatabaseConnection>, | |
| 348 | + Form(form): Form<UpdateFediverseSettingsForm>, | |
| 349 | +) -> Result<impl IntoResponse, AppError> { | |
| 350 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 351 | + | |
| 352 | + if !TRUST_MODES.contains(&form.fediverse_trust_mode.as_str()) { | |
| 353 | + return Err(AppError::Validation(format!( | |
| 354 | + "invalid trust_mode: {}", | |
| 355 | + form.fediverse_trust_mode | |
| 356 | + ))); | |
| 357 | + } | |
| 358 | + if !INTEROP_SCOPES.contains(&form.fediverse_interop_scope.as_str()) { | |
| 359 | + return Err(AppError::Validation(format!( | |
| 360 | + "invalid interop_scope: {}", | |
| 361 | + form.fediverse_interop_scope | |
| 362 | + ))); | |
| 363 | + } | |
| 364 | + | |
| 365 | + let bool_str = |opt: &Option<String>| { | |
| 366 | + if opt.is_some() { | |
| 367 | + "true".to_string() | |
| 368 | + } else { | |
| 369 | + "false".to_string() | |
| 370 | + } | |
| 371 | + }; | |
| 372 | + | |
| 373 | + let updated_by = Some(claims.sub); | |
| 374 | + | |
| 375 | + AppSettingsService::set_setting( | |
| 376 | + &db, | |
| 377 | + "fediverse_enabled".to_string(), | |
| 378 | + bool_str(&form.fediverse_enabled), | |
| 379 | + updated_by, | |
| 380 | + ) | |
| 381 | + .await?; | |
| 382 | + AppSettingsService::set_setting( | |
| 383 | + &db, | |
| 384 | + "fediverse_trust_mode".to_string(), | |
| 385 | + form.fediverse_trust_mode, | |
| 386 | + updated_by, | |
| 387 | + ) | |
| 388 | + .await?; | |
| 389 | + AppSettingsService::set_setting( | |
| 390 | + &db, | |
| 391 | + "fediverse_interop_scope".to_string(), | |
| 392 | + form.fediverse_interop_scope, | |
| 393 | + updated_by, | |
| 394 | + ) | |
| 395 | + .await?; | |
| 396 | + AppSettingsService::set_setting( | |
| 397 | + &db, | |
| 398 | + "fediverse_authorized_fetch".to_string(), | |
| 399 | + bool_str(&form.fediverse_authorized_fetch), | |
| 400 | + updated_by, | |
| 401 | + ) | |
| 402 | + .await?; | |
| 403 | + | |
| 404 | + Ok(Redirect::to("/admin/fediverse/settings?saved=1")) | |
| 405 | +} | |
| 406 | + | |
| 407 | +// ───────────────────────────────────────────────────────────────────── | |
| 408 | +// /admin/fediverse/trust_list (RFC 0021 Slice A iter. 2) | |
| 409 | +// ───────────────────────────────────────────────────────────────────── | |
| 410 | + | |
| 411 | +/// One row rendered into the trust-list table. Whether it represents a | |
| 412 | +/// trusted or a blocked domain is conveyed by the surrounding template | |
| 413 | +/// state (`is_blocklist`). | |
| 414 | +#[derive(Debug, Clone)] | |
| 415 | +pub struct TrustEntryCtx { | |
| 416 | + /// Normalised domain. | |
| 417 | + pub domain: String, | |
| 418 | + /// Optional admin note (allowlist) or blocking reason (blocklist). | |
| 419 | + pub note_or_reason: String, | |
| 420 | + /// `"true"` when [`Self::note_or_reason`] is non-empty (Askama lacks | |
| 421 | + /// a clean `Option` ergonomic for templates). | |
| 422 | + pub has_note_or_reason: bool, | |
| 423 | + /// Severity (blocklist only): `suspend` / `silence` / `reject_auth`. | |
| 424 | + /// Empty string for trusted entries. | |
| 425 | + pub severity: String, | |
| 426 | + /// `created_at` formatted as `YYYY-MM-DD`. | |
| 427 | + pub created_at: String, | |
| 428 | +} | |
| 429 | + | |
| 430 | +/// Form payload for `POST /admin/fediverse/trust_list/add`. | |
| 431 | +#[derive(Debug, Deserialize)] | |
| 432 | +pub struct AddTrustEntryForm { | |
| 433 | + /// CSRF token from the hidden form field. | |
| 434 | + pub csrf_token: String, | |
| 435 | + /// Domain submitted by the admin (free-form, normalised on insert). | |
| 436 | + pub domain: String, | |
| 437 | + /// Optional note (allowlist) or reason (blocklist). | |
| 438 | + #[serde(default)] | |
| 439 | + pub note_or_reason: String, | |
| 440 | + /// Blocklist severity: `suspend` / `silence` / `reject_auth`. Ignored | |
| 441 | + /// when the active mode is `allowlist`. | |
| 442 | + #[serde(default)] | |
| 443 | + pub severity: String, | |
| 444 | +} | |
| 445 | + | |
| 446 | +/// Form payload for `POST /admin/fediverse/trust_list/delete`. | |
| 447 | +#[derive(Debug, Deserialize)] | |
| 448 | +pub struct DeleteTrustEntryForm { | |
| 449 | + /// CSRF token from the hidden form field. | |
| 450 | + pub csrf_token: String, | |
| 451 | + /// Domain to remove from the active list. | |
| 452 | + pub domain: String, | |
| 453 | +} | |
| 454 | + | |
| 455 | +/// Askama template for `GET /admin/fediverse/trust_list`. The fields | |
| 456 | +/// below carry pre-translated UI strings whose intent is conveyed by | |
| 457 | +/// their names; verbose per-field rustdoc would be noise on a 38-key | |
| 458 | +/// table. | |
| 459 | +#[derive(Template)] | |
| 460 | +#[template(path = "admin/trust_list.html")] | |
| 461 | +#[allow(dead_code, missing_docs)] | |
| 462 | +pub struct FediverseTrustListTemplate { | |
| 463 | + /// Browser tab + page title. | |
| 464 | + pub page_title: String, | |
| 465 | + /// CSRF token for the add + delete forms. | |
| 466 | + pub csrf_token: String, | |
| 467 | + /// App display fields shared with `settings.html`. | |
| 468 | + pub app_name: String, | |
| 469 | + pub app_version: String, | |
| 470 | + pub app_domain: String, | |
| 471 | + pub app_description: String, | |
| 472 | + pub username: String, | |
| 473 | + pub email: String, | |
| 474 | + pub initials: String, | |
| 475 | + pub avatar_color: String, | |
| 476 | + pub roles_text: String, | |
| 477 | + pub has_pending_password_change: bool, | |
| 478 | + pub nav_items: Vec<NavItem>, | |
| 479 | + pub user_menu_profile: String, | |
| 480 | + pub user_menu_logout: String, | |
| 481 | + /// Toast banner shown when `?saved=1`. | |
| 482 | + pub saved_flash: bool, | |
| 483 | + /// `true` when the active mode is `open_with_blocklist` — drives | |
| 484 | + /// table headers and form fields (severity selector vs note). | |
| 485 | + pub is_blocklist: bool, | |
| 486 | + /// Pre-loaded entries. | |
| 487 | + pub entries: Vec<TrustEntryCtx>, | |
| 488 | + /// Whether [`Self::entries`] is empty (Askama lacks `is_empty()` | |
| 489 | + /// in stable expressions across versions). | |
| 490 | + pub has_entries: bool, | |
| 491 | + | |
| 492 | + // i18n | |
| 493 | + pub heading: String, | |
| 494 | + pub subtitle: String, | |
| 495 | + pub mode_indicator: String, | |
| 496 | + pub add_form_legend: String, | |
| 497 | + pub domain_label: String, | |
| 498 | + pub domain_placeholder: String, | |
| 499 | + pub note_label: String, | |
| 500 | + pub reason_label: String, | |
| 501 | + pub severity_label: String, | |
| 502 | + pub severity_suspend: String, | |
| 503 | + pub severity_silence: String, | |
| 504 | + pub severity_reject_auth: String, | |
| 505 | + pub add_button: String, | |
| 506 | + pub table_domain: String, | |
| 507 | + pub table_note_or_reason: String, | |
| 508 | + pub table_severity: String, | |
| 509 | + pub table_created_at: String, | |
| 510 | + pub table_actions: String, | |
| 511 | + pub delete_button: String, | |
| 512 | + pub delete_confirm_title: String, | |
| 513 | + pub delete_confirm_body: String, | |
| 514 | + pub modal_cancel: String, | |
| 515 | + pub modal_confirm: String, | |
| 516 | + pub empty_title: String, | |
| 517 | + pub empty_body: String, | |
| 518 | + pub saved_toast: String, | |
| 519 | + pub breadcrumb_home: String, | |
| 520 | + pub breadcrumb_admin: String, | |
| 521 | + pub breadcrumb_federation: String, | |
| 522 | + pub breadcrumb_self: String, | |
| 523 | + pub tabs: Vec<AdminTabDTO>, | |
| 524 | + pub active_tab: String, | |
| 525 | +} | |
| 526 | + | |
| 527 | +rustwarden_core::impl_template_response!(FediverseTrustListTemplate); | |
| 528 | + | |
| 529 | +/// Allowed `severity` form values (RFC 0004). | |
| 530 | +const SEVERITIES: &[&str] = &["suspend", "silence", "reject_auth"]; | |
| 531 | + | |
| 532 | +/// Render `GET /admin/fediverse/trust_list`. | |
| 533 | +/// | |
| 534 | +/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` so the | |
| 535 | +/// page is invisible while the feature is off. The active list (allow | |
| 536 | +/// vs block) is selected from `app_settings.fediverse_trust_mode`. | |
| 537 | +pub async fn get_trust_list( | |
| 538 | + Extension(claims): Extension<Claims>, | |
| 539 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 540 | + Extension(locale): Extension<Locale>, | |
| 541 | + State(db): State<DatabaseConnection>, | |
| 542 | + session: Session, | |
| 543 | + axum::extract::Query(query): axum::extract::Query<SavedQuery>, | |
| 544 | +) -> Result<axum::response::Response, AppError> { | |
| 545 | + let trust = FediverseTrustService::new(db.clone()); | |
| 546 | + | |
| 547 | + if !trust.is_enabled().await? { | |
| 548 | + return Ok(( | |
| 549 | + axum::http::StatusCode::NOT_FOUND, | |
| 550 | + "Federation disabled", | |
| 551 | + ) | |
| 552 | + .into_response()); | |
| 553 | + } | |
| 554 | + | |
| 555 | + let locale_str = locale.as_str(); | |
| 556 | + let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 557 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 558 | + | |
| 559 | + let mode = trust.trust_mode().await?; | |
| 560 | + let is_blocklist = matches!(mode, TrustMode::OpenWithBlocklist); | |
| 561 | + | |
| 562 | + let entries: Vec<TrustEntryCtx> = if is_blocklist { | |
| 563 | + trust | |
| 564 | + .list_blocked_domains() | |
| 565 | + .await? | |
| 566 | + .into_iter() | |
| 567 | + .map(|m| TrustEntryCtx { | |
| 568 | + domain: m.domain, | |
| 569 | + has_note_or_reason: m.reason.as_deref().is_some_and(|s| !s.is_empty()), | |
| 570 | + note_or_reason: m.reason.unwrap_or_default(), | |
| 571 | + severity: m.severity, | |
| 572 | + created_at: m.created_at.format("%Y-%m-%d").to_string(), | |
| 573 | + }) | |
| 574 | + .collect() | |
| 575 | + } else { | |
| 576 | + trust | |
| 577 | + .list_trusted_domains() | |
| 578 | + .await? | |
| 579 | + .into_iter() | |
| 580 | + .map(|m| TrustEntryCtx { | |
| 581 | + domain: m.domain, | |
| 582 | + has_note_or_reason: m.note.as_deref().is_some_and(|s| !s.is_empty()), | |
| 583 | + note_or_reason: m.note.unwrap_or_default(), | |
| 584 | + severity: String::new(), | |
| 585 | + created_at: m.created_at.format("%Y-%m-%d").to_string(), | |
| 586 | + }) | |
| 587 | + .collect() | |
| 588 | + }; | |
| 589 | + let has_entries = !entries.is_empty(); | |
| 590 | + | |
| 591 | + let t = |k: &str| { | |
| 592 | + let i18n = i18n_service.clone(); | |
| 593 | + let key = k.to_string(); | |
| 594 | + let loc = locale_str.to_string(); | |
| 595 | + async move { i18n.translate(&key, &loc).await } | |
| 596 | + }; | |
| 597 | + | |
| 598 | + let mode_indicator = if is_blocklist { | |
| 599 | + t("fediverse.trust.mode_blocklist").await | |
| 600 | + } else { | |
| 601 | + t("fediverse.trust.mode_allowlist").await | |
| 602 | + }; | |
| 603 | + | |
| 604 | + let template = FediverseTrustListTemplate { | |
| 605 | + page_title: t("fediverse.trust.title").await, | |
| 606 | + csrf_token, | |
| 607 | + app_name: base_context.app_name, | |
| 608 | + app_version: base_context.app_version, | |
| 609 | + app_domain: base_context.app_domain, | |
| 610 | + app_description: base_context.app_description, | |
| 611 | + username: base_context.username, | |
| 612 | + email: base_context.email, | |
| 613 | + initials: base_context.initials, | |
| 614 | + avatar_color: base_context.avatar_color, | |
| 615 | + roles_text: base_context.roles.join(", "), | |
| 616 | + has_pending_password_change: false, | |
| 617 | + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 618 | + .await, | |
| 619 | + user_menu_profile: t("user_menu.profile").await, | |
| 620 | + user_menu_logout: t("user_menu.logout").await, | |
| 621 | + saved_flash: query.saved.unwrap_or(0) == 1, | |
| 622 | + is_blocklist, | |
| 623 | + entries, | |
| 624 | + has_entries, | |
| 625 | + heading: t("fediverse.trust.heading").await, | |
| 626 | + subtitle: t("fediverse.trust.subtitle").await, | |
| 627 | + mode_indicator, | |
| 628 | + add_form_legend: t("fediverse.trust.add_legend").await, | |
| 629 | + domain_label: t("fediverse.trust.domain_label").await, | |
| 630 | + domain_placeholder: t("fediverse.trust.domain_placeholder").await, | |
| 631 | + note_label: t("fediverse.trust.note_label").await, | |
| 632 | + reason_label: t("fediverse.trust.reason_label").await, | |
| 633 | + severity_label: t("fediverse.trust.severity_label").await, | |
| 634 | + severity_suspend: t("fediverse.trust.severity_suspend").await, | |
| 635 | + severity_silence: t("fediverse.trust.severity_silence").await, | |
| 636 | + severity_reject_auth: t("fediverse.trust.severity_reject_auth").await, | |
| 637 | + add_button: t("fediverse.trust.add_button").await, | |
| 638 | + table_domain: t("fediverse.trust.table_domain").await, | |
| 639 | + table_note_or_reason: t("fediverse.trust.table_note_or_reason").await, | |
| 640 | + table_severity: t("fediverse.trust.table_severity").await, | |
| 641 | + table_created_at: t("fediverse.trust.table_created_at").await, | |
| 642 | + table_actions: t("fediverse.trust.table_actions").await, | |
| 643 | + delete_button: t("fediverse.trust.delete_button").await, | |
| 644 | + delete_confirm_title: t("fediverse.trust.delete_confirm_title").await, | |
| 645 | + delete_confirm_body: t("fediverse.trust.delete_confirm_body").await, | |
| 646 | + modal_cancel: t("fediverse.trust.modal_cancel").await, | |
| 647 | + modal_confirm: t("fediverse.trust.modal_confirm").await, | |
| 648 | + empty_title: t("fediverse.trust.empty_title").await, | |
| 649 | + empty_body: t("fediverse.trust.empty_body").await, | |
| 650 | + saved_toast: t("fediverse.trust.saved_toast").await, | |
| 651 | + breadcrumb_home: t("breadcrumb.home").await, | |
| 652 | + breadcrumb_admin: t("breadcrumb.admin").await, | |
| 653 | + breadcrumb_federation: t("fediverse.admin.hub.title").await, | |
| 654 | + breadcrumb_self: t("fediverse.trust.breadcrumb").await, | |
| 655 | + tabs: admin_tabs(&i18n_service, locale_str).await, | |
| 656 | + active_tab: "trust_list".to_string(), | |
| 657 | + }; | |
| 658 | + | |
| 659 | + Ok(template.into_response()) | |
| 660 | +} | |
| 661 | + | |
| 662 | +/// Apply `POST /admin/fediverse/trust_list/add`. Adds an entry to the | |
| 663 | +/// allowlist or the blocklist depending on the current `trust_mode`. | |
| 664 | +pub async fn add_trust_entry( | |
| 665 | + Extension(claims): Extension<Claims>, | |
| 666 | + State(db): State<DatabaseConnection>, | |
| 667 | + session: Session, | |
| 668 | + Form(form): Form<AddTrustEntryForm>, | |
| 669 | +) -> Result<impl IntoResponse, AppError> { | |
| 670 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 671 | + | |
| 672 | + let domain = form.domain.trim(); | |
| 673 | + if domain.is_empty() { | |
| 674 | + return Err(AppError::Validation("domain must not be empty".into())); | |
| 675 | + } | |
| 676 | + | |
| 677 | + let trust = FediverseTrustService::new(db.clone()); | |
| 678 | + if !trust.is_enabled().await? { | |
| 679 | + return Ok(axum::http::StatusCode::NOT_FOUND.into_response()); | |
| 680 | + } | |
| 681 | + let mode = trust.trust_mode().await?; | |
| 682 | + let added_by = Some(claims.sub); | |
| 683 | + let note_opt = if form.note_or_reason.trim().is_empty() { | |
| 684 | + None | |
| 685 | + } else { | |
| 686 | + Some(form.note_or_reason.trim()) | |
| 687 | + }; | |
| 688 | + | |
| 689 | + match mode { | |
| 690 | + TrustMode::Allowlist => { | |
| 691 | + trust | |
| 692 | + .add_trusted_domain(domain, note_opt, added_by) | |
| 693 | + .await?; | |
| 694 | + } | |
| 695 | + TrustMode::OpenWithBlocklist => { | |
| 696 | + if !SEVERITIES.contains(&form.severity.as_str()) { | |
| 697 | + return Err(AppError::Validation(format!( | |
| 698 | + "invalid severity: {}", | |
| 699 | + form.severity | |
| 700 | + ))); | |
| 701 | + } | |
| 702 | + let severity = BlockSeverity::parse(&form.severity); | |
| 703 | + trust | |
| 704 | + .add_blocked_domain(domain, severity, note_opt, added_by) | |
| 705 | + .await?; | |
| 706 | + // F9 (iter 4 / S4) — auto-kill: blocking a domain must | |
| 707 | + // immediately disable any active `remote_identities` for | |
| 708 | + // that domain. Without this, the admin has to perform two | |
| 709 | + // separate actions for a single intent (« cut this hostile | |
| 710 | + // instance »). The middleware F10 check then rejects all | |
| 711 | + // future protected requests carrying a JWT bound to the | |
| 712 | + // disabled identity. | |
| 713 | + use rustwarden_fediverse::services::fediverse_trust_service::normalize_domain; | |
| 714 | + use sea_orm::sea_query::Expr; | |
| 715 | + let now = chrono::Utc::now(); | |
| 716 | + let normalized = normalize_domain(domain); | |
| 717 | + remote_identity::Entity::update_many() | |
| 718 | + .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) | |
| 719 | + .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) | |
| 720 | + .filter(remote_identity::Column::Domain.eq(normalized)) | |
| 721 | + .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 722 | + .exec(&db) | |
| 723 | + .await?; | |
| 724 | + } | |
| 725 | + } | |
| 726 | + | |
| 727 | + Ok(Redirect::to("/admin/fediverse/trust_list?saved=1").into_response()) | |
| 728 | +} | |
| 729 | + | |
| 730 | +/// Apply `POST /admin/fediverse/trust_list/delete`. Removes an entry by | |
| 731 | +/// canonical domain (URL-safe form input) from the active list. | |
| 732 | +pub async fn delete_trust_entry( | |
| 733 | + State(db): State<DatabaseConnection>, | |
| 734 | + session: Session, | |
| 735 | + Form(form): Form<DeleteTrustEntryForm>, | |
| 736 | +) -> Result<impl IntoResponse, AppError> { | |
| 737 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 738 | + | |
| 739 | + let trust = FediverseTrustService::new(db.clone()); | |
| 740 | + if !trust.is_enabled().await? { | |
| 741 | + return Ok(axum::http::StatusCode::NOT_FOUND.into_response()); | |
| 742 | + } | |
| 743 | + let mode = trust.trust_mode().await?; | |
| 744 | + | |
| 745 | + match mode { | |
| 746 | + TrustMode::Allowlist => { | |
| 747 | + trust.remove_trusted_domain(&form.domain).await?; | |
| 748 | + } | |
| 749 | + TrustMode::OpenWithBlocklist => { | |
| 750 | + trust.remove_blocked_domain(&form.domain).await?; | |
| 751 | + } | |
| 752 | + } | |
| 753 | + | |
| 754 | + Ok(Redirect::to("/admin/fediverse/trust_list?saved=1").into_response()) | |
| 755 | +} | |
| 756 | + | |
| 757 | +// ───────────────────────────────────────────────────────────────────── | |
| 758 | +// /admin/fediverse/queue (RFC 0024 Slice C iter. 1) | |
| 759 | +// ───────────────────────────────────────────────────────────────────── | |
| 760 | + | |
| 761 | +/// One row rendered into the outbound-queue table. | |
| 762 | +#[derive(Debug, Clone)] | |
| 763 | +pub struct OutboundQueueRowCtx { | |
| 764 | + /// Activity ID (truncated for display). | |
| 765 | + pub activity_id: String, | |
| 766 | + /// Target inbox URL. | |
| 767 | + pub target_inbox: String, | |
| 768 | + /// Target domain extracted by `FediverseOutboundService::enqueue`. | |
| 769 | + pub target_domain: String, | |
| 770 | + /// `pending` / `delivered` / `failed` / `dead`. | |
| 771 | + pub status: String, | |
| 772 | + /// Number of attempts already performed. | |
| 773 | + pub attempts: i32, | |
| 774 | + /// Maximum attempts before terminal `dead`. | |
| 775 | + pub max_attempts: i32, | |
| 776 | + /// Last delivery error truncated. | |
| 777 | + pub last_error: String, | |
| 778 | + /// `true` when [`Self::last_error`] is non-empty. | |
| 779 | + pub has_last_error: bool, | |
| 780 | + /// `next_retry_at` formatted RFC3339, empty when `None`. | |
| 781 | + pub next_retry_at: String, | |
| 782 | + /// Insertion timestamp formatted `YYYY-MM-DD HH:MM:SS`. | |
| 783 | + pub created_at: String, | |
| 784 | +} | |
| 785 | + | |
| 786 | +/// Askama template for `GET /admin/fediverse/queue`. | |
| 787 | +#[derive(Template)] | |
| 788 | +#[template(path = "admin/queue.html")] | |
| 789 | +#[allow(dead_code, missing_docs)] | |
| 790 | +pub struct FediverseQueueTemplate { | |
| 791 | + pub page_title: String, | |
| 792 | + pub csrf_token: String, | |
| 793 | + pub app_name: String, | |
| 794 | + pub app_version: String, | |
| 795 | + pub app_domain: String, | |
| 796 | + pub app_description: String, | |
| 797 | + pub username: String, | |
| 798 | + pub email: String, | |
| 799 | + pub initials: String, | |
| 800 | + pub avatar_color: String, | |
| 801 | + pub roles_text: String, | |
| 802 | + pub has_pending_password_change: bool, | |
| 803 | + pub nav_items: Vec<NavItem>, | |
| 804 | + pub user_menu_profile: String, | |
| 805 | + pub user_menu_logout: String, | |
| 806 | + | |
| 807 | + /// Pre-loaded last 100 rows ordered by `created_at desc`. | |
| 808 | + pub rows: Vec<OutboundQueueRowCtx>, | |
| 809 | + /// `true` when [`Self::rows`] is empty. | |
| 810 | + pub has_rows: bool, | |
| 811 | + /// Per-status counts (sums all rows, not just the 100 visible). | |
| 812 | + pub count_pending: u64, | |
| 813 | + pub count_delivered: u64, | |
| 814 | + pub count_failed: u64, | |
| 815 | + pub count_dead: u64, | |
| 816 | + | |
| 817 | + pub heading: String, | |
| 818 | + pub subtitle: String, | |
| 819 | + pub stats_pending: String, | |
| 820 | + pub stats_delivered: String, | |
| 821 | + pub stats_failed: String, | |
| 822 | + pub stats_dead: String, | |
| 823 | + pub table_activity: String, | |
| 824 | + pub table_target: String, | |
| 825 | + pub table_status: String, | |
| 826 | + pub table_attempts: String, | |
| 827 | + pub table_last_error: String, | |
| 828 | + pub table_next_retry: String, | |
| 829 | + pub table_created_at: String, | |
| 830 | + pub empty_title: String, | |
| 831 | + pub empty_body: String, | |
| 832 | + pub breadcrumb_home: String, | |
| 833 | + pub breadcrumb_admin: String, | |
| 834 | + pub breadcrumb_federation: String, | |
| 835 | + pub breadcrumb_self: String, | |
| 836 | + pub tabs: Vec<AdminTabDTO>, | |
| 837 | + pub active_tab: String, | |
| 838 | +} | |
| 839 | + | |
| 840 | +rustwarden_core::impl_template_response!(FediverseQueueTemplate); | |
| 841 | + | |
| 842 | +/// Truncate a string to `max` chars, appending `…` when cut. | |
| 843 | +fn truncate(s: &str, max: usize) -> String { | |
| 844 | + if s.chars().count() <= max { | |
| 845 | + s.to_string() | |
| 846 | + } else { | |
| 847 | + let head: String = s.chars().take(max.saturating_sub(1)).collect(); | |
| 848 | + format!("{head}…") | |
| 849 | + } | |
| 850 | +} | |
| 851 | + | |
| 852 | +/// Render `GET /admin/fediverse/queue` — operator dashboard for the | |
| 853 | +/// outbound delivery queue (RFC 0011 + RFC 0019). Shows the last 100 | |
| 854 | +/// rows + per-status counts so an admin can spot a backlog at a glance. | |
| 855 | +/// | |
| 856 | +/// Returns `404` when `fediverse_enabled = false` (Plan UI §3 P9). | |
| 857 | +pub async fn get_queue( | |
| 858 | + Extension(claims): Extension<Claims>, | |
| 859 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 860 | + Extension(locale): Extension<Locale>, | |
| 861 | + State(db): State<DatabaseConnection>, | |
| 862 | + session: Session, | |
| 863 | +) -> Result<axum::response::Response, AppError> { | |
| 864 | + let trust = FediverseTrustService::new(db.clone()); | |
| 865 | + if !trust.is_enabled().await? { | |
| 866 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 867 | + } | |
| 868 | + | |
| 869 | + let locale_str = locale.as_str(); | |
| 870 | + let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 871 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 872 | + | |
| 873 | + let count_pending = fediverse_outbound_queue::Entity::find() | |
| 874 | + .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Pending.as_str())) | |
| 875 | + .count(&db) | |
| 876 | + .await | |
| 877 | + .unwrap_or(0); | |
| 878 | + let count_delivered = fediverse_outbound_queue::Entity::find() | |
| 879 | + .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Delivered.as_str())) | |
| 880 | + .count(&db) | |
| 881 | + .await | |
| 882 | + .unwrap_or(0); | |
| 883 | + let count_failed = fediverse_outbound_queue::Entity::find() | |
| 884 | + .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Failed.as_str())) | |
| 885 | + .count(&db) | |
| 886 | + .await | |
| 887 | + .unwrap_or(0); | |
| 888 | + let count_dead = fediverse_outbound_queue::Entity::find() | |
| 889 | + .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Dead.as_str())) | |
| 890 | + .count(&db) | |
| 891 | + .await | |
| 892 | + .unwrap_or(0); | |
| 893 | + | |
| 894 | + let rows_raw = fediverse_outbound_queue::Entity::find() | |
| 895 | + .order_by_desc(fediverse_outbound_queue::Column::CreatedAt) | |
| 896 | + .limit(100) | |
| 897 | + .all(&db) | |
| 898 | + .await?; | |
| 899 | + | |
| 900 | + let rows: Vec<OutboundQueueRowCtx> = rows_raw | |
| 901 | + .into_iter() | |
| 902 | + .map(|m| OutboundQueueRowCtx { | |
| 903 | + activity_id: truncate(&m.activity_id, 60), | |
| 904 | + target_inbox: truncate(&m.target_inbox, 60), | |
| 905 | + target_domain: m.target_domain, | |
| 906 | + status: m.status, | |
| 907 | + attempts: m.attempts, | |
| 908 | + max_attempts: m.max_attempts, | |
| 909 | + has_last_error: m.last_error.as_deref().is_some_and(|s| !s.is_empty()), | |
| 910 | + last_error: m | |
| 911 | + .last_error | |
| 912 | + .map(|e| truncate(&e, 80)) | |
| 913 | + .unwrap_or_default(), | |
| 914 | + next_retry_at: m | |
| 915 | + .next_retry_at | |
| 916 | + .map(|t| t.to_rfc3339()) | |
| 917 | + .unwrap_or_default(), | |
| 918 | + created_at: m.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), | |
| 919 | + }) | |
| 920 | + .collect(); | |
| 921 | + let has_rows = !rows.is_empty(); | |
| 922 | + | |
| 923 | + let t = |k: &str| { | |
| 924 | + let i18n = i18n_service.clone(); | |
| 925 | + let key = k.to_string(); | |
| 926 | + let loc = locale_str.to_string(); | |
| 927 | + async move { i18n.translate(&key, &loc).await } | |
| 928 | + }; | |
| 929 | + | |
| 930 | + let template = FediverseQueueTemplate { | |
| 931 | + page_title: t("fediverse.queue.title").await, | |
| 932 | + csrf_token, | |
| 933 | + app_name: base_context.app_name, | |
| 934 | + app_version: base_context.app_version, | |
| 935 | + app_domain: base_context.app_domain, | |
| 936 | + app_description: base_context.app_description, | |
| 937 | + username: base_context.username, | |
| 938 | + email: base_context.email, | |
| 939 | + initials: base_context.initials, | |
| 940 | + avatar_color: base_context.avatar_color, | |
| 941 | + roles_text: base_context.roles.join(", "), | |
| 942 | + has_pending_password_change: false, | |
| 943 | + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 944 | + .await, | |
| 945 | + user_menu_profile: t("user_menu.profile").await, | |
| 946 | + user_menu_logout: t("user_menu.logout").await, | |
| 947 | + | |
| 948 | + rows, | |
| 949 | + has_rows, | |
| 950 | + count_pending, | |
| 951 | + count_delivered, | |
| 952 | + count_failed, | |
| 953 | + count_dead, | |
| 954 | + | |
| 955 | + heading: t("fediverse.queue.heading").await, | |
| 956 | + subtitle: t("fediverse.queue.subtitle").await, | |
| 957 | + stats_pending: t("fediverse.queue.stats_pending").await, | |
| 958 | + stats_delivered: t("fediverse.queue.stats_delivered").await, | |
| 959 | + stats_failed: t("fediverse.queue.stats_failed").await, | |
| 960 | + stats_dead: t("fediverse.queue.stats_dead").await, | |
| 961 | + table_activity: t("fediverse.queue.table_activity").await, | |
| 962 | + table_target: t("fediverse.queue.table_target").await, | |
| 963 | + table_status: t("fediverse.queue.table_status").await, | |
| 964 | + table_attempts: t("fediverse.queue.table_attempts").await, | |
| 965 | + table_last_error: t("fediverse.queue.table_last_error").await, | |
| 966 | + table_next_retry: t("fediverse.queue.table_next_retry").await, | |
| 967 | + table_created_at: t("fediverse.queue.table_created_at").await, | |
| 968 | + empty_title: t("fediverse.queue.empty_title").await, | |
| 969 | + empty_body: t("fediverse.queue.empty_body").await, | |
| 970 | + breadcrumb_home: t("breadcrumb.home").await, | |
| 971 | + breadcrumb_admin: t("breadcrumb.admin").await, | |
| 972 | + breadcrumb_federation: t("fediverse.admin.hub.title").await, | |
| 973 | + breadcrumb_self: t("fediverse.queue.breadcrumb").await, | |
| 974 | + tabs: admin_tabs(&i18n_service, locale_str).await, | |
| 975 | + active_tab: "queue".to_string(), | |
| 976 | + }; | |
| 977 | + | |
| 978 | + Ok(template.into_response()) | |
| 979 | +} | |
| 980 | + | |
| 981 | +// ───────────────────────────────────────────────────────────────────── | |
| 982 | +// /admin/fediverse/sessions_remote (RFC 0024 Slice C iter. 2) | |
| 983 | +// ───────────────────────────────────────────────────────────────────── | |
| 984 | + | |
| 985 | +/// One row rendered into the sessions-remote table. | |
| 986 | +#[derive(Debug, Clone)] | |
| 987 | +pub struct RemoteSessionRowCtx { | |
| 988 | + /// PK of the remote_identities row. | |
| 989 | + pub id: String, | |
| 990 | + /// Canonical AP id (`https://b.tld/users/alice`), truncated. | |
| 991 | + pub ap_id: String, | |
| 992 | + /// `@user@domain` recomposed for human display. | |
| 993 | + pub handle: String, | |
| 994 | + /// Display name advertised by the actor (or empty). | |
| 995 | + pub display_name: String, | |
| 996 | + /// Whether [`Self::display_name`] is non-empty. | |
| 997 | + pub has_display_name: bool, | |
| 998 | + /// Domain of the federated instance. | |
| 999 | + pub domain: String, | |
| 1000 | + /// `last_fetched_at` formatted `YYYY-MM-DD HH:MM`. | |
| 1001 | + pub last_seen: String, | |
| 1002 | +} | |
| 1003 | + | |
| 1004 | +/// Form payload for `POST /admin/fediverse/sessions_remote/revoke`. | |
| 1005 | +#[derive(Debug, Deserialize)] | |
| 1006 | +pub struct RevokeRemoteForm { | |
| 1007 | + /// CSRF token from the hidden form field. | |
| 1008 | + pub csrf_token: String, | |
| 1009 | + /// PK of the remote_identities row to disable. | |
| 1010 | + pub id: String, | |
| 1011 | +} | |
| 1012 | + | |
| 1013 | +/// Form payload for `POST /admin/fediverse/sessions_remote/kill_domain`. | |
| 1014 | +#[derive(Debug, Deserialize)] | |
| 1015 | +pub struct KillDomainForm { | |
| 1016 | + /// CSRF token from the hidden form field. | |
| 1017 | + pub csrf_token: String, | |
| 1018 | + /// Domain whose remote_identities rows should be disabled in bulk. | |
| 1019 | + pub domain: String, | |
| 1020 | +} | |
| 1021 | + | |
| 1022 | +/// Askama template for `GET /admin/fediverse/sessions_remote`. | |
| 1023 | +#[derive(Template)] | |
| 1024 | +#[template(path = "admin/sessions_remote.html")] | |
| 1025 | +#[allow(dead_code, missing_docs)] | |
| 1026 | +pub struct FediverseSessionsRemoteTemplate { | |
| 1027 | + pub page_title: String, | |
| 1028 | + pub csrf_token: String, | |
| 1029 | + pub app_name: String, | |
| 1030 | + pub app_version: String, | |
| 1031 | + pub app_domain: String, | |
| 1032 | + pub app_description: String, | |
| 1033 | + pub username: String, | |
| 1034 | + pub email: String, | |
| 1035 | + pub initials: String, | |
| 1036 | + pub avatar_color: String, | |
| 1037 | + pub roles_text: String, | |
| 1038 | + pub has_pending_password_change: bool, | |
| 1039 | + pub nav_items: Vec<NavItem>, | |
| 1040 | + pub user_menu_profile: String, | |
| 1041 | + pub user_menu_logout: String, | |
| 1042 | + | |
| 1043 | + pub saved_flash: bool, | |
| 1044 | + pub rows: Vec<RemoteSessionRowCtx>, | |
| 1045 | + pub has_rows: bool, | |
| 1046 | + /// Distinct domains present in [`Self::rows`] for the kill-switch | |
| 1047 | + /// `<select>`. | |
| 1048 | + pub distinct_domains: Vec<String>, | |
| 1049 | + | |
| 1050 | + pub heading: String, | |
| 1051 | + pub subtitle: String, | |
| 1052 | + pub kill_switch_legend: String, | |
| 1053 | + pub kill_switch_label: String, | |
| 1054 | + pub kill_switch_button: String, | |
| 1055 | + pub kill_switch_confirm_title: String, | |
| 1056 | + pub kill_switch_confirm_body: String, | |
| 1057 | + pub table_handle: String, | |
| 1058 | + pub table_display_name: String, | |
| 1059 | + pub table_domain: String, | |
| 1060 | + pub table_last_seen: String, | |
| 1061 | + pub table_actions: String, | |
| 1062 | + pub revoke_button: String, | |
| 1063 | + pub revoke_confirm_title: String, | |
| 1064 | + pub revoke_confirm_body: String, | |
| 1065 | + pub modal_cancel: String, | |
| 1066 | + pub modal_confirm: String, | |
| 1067 | + pub empty_title: String, | |
| 1068 | + pub empty_body: String, | |
| 1069 | + pub saved_toast: String, | |
| 1070 | + pub breadcrumb_home: String, | |
| 1071 | + pub breadcrumb_admin: String, | |
| 1072 | + pub breadcrumb_federation: String, | |
| 1073 | + pub breadcrumb_self: String, | |
| 1074 | + pub tabs: Vec<AdminTabDTO>, | |
| 1075 | + pub active_tab: String, | |
| 1076 | +} | |
| 1077 | + | |
| 1078 | +rustwarden_core::impl_template_response!(FediverseSessionsRemoteTemplate); | |
| 1079 | + | |
| 1080 | +/// Render `GET /admin/fediverse/sessions_remote` — list active federated | |
| 1081 | +/// identities (rows with `disabled_at IS NULL`). | |
| 1082 | +pub async fn get_sessions_remote( | |
| 1083 | + Extension(claims): Extension<Claims>, | |
| 1084 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 1085 | + Extension(locale): Extension<Locale>, | |
| 1086 | + State(db): State<DatabaseConnection>, | |
| 1087 | + session: Session, | |
| 1088 | + axum::extract::Query(query): axum::extract::Query<SavedQuery>, | |
| 1089 | +) -> Result<axum::response::Response, AppError> { | |
| 1090 | + let trust = FediverseTrustService::new(db.clone()); | |
| 1091 | + if !trust.is_enabled().await? { | |
| 1092 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1093 | + } | |
| 1094 | + | |
| 1095 | + let locale_str = locale.as_str(); | |
| 1096 | + let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 1097 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 1098 | + | |
| 1099 | + let active_rows = remote_identity::Entity::find() | |
| 1100 | + .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 1101 | + .order_by_desc(remote_identity::Column::LastFetchedAt) | |
| 1102 | + .limit(200) | |
| 1103 | + .all(&db) | |
| 1104 | + .await?; | |
| 1105 | + | |
| 1106 | + let rows: Vec<RemoteSessionRowCtx> = active_rows | |
| 1107 | + .iter() | |
| 1108 | + .map(|m| RemoteSessionRowCtx { | |
| 1109 | + id: m.id.to_string(), | |
| 1110 | + ap_id: truncate(&m.ap_id, 60), | |
| 1111 | + handle: format!("@{}@{}", m.username, m.domain), | |
| 1112 | + has_display_name: m | |
| 1113 | + .display_name | |
| 1114 | + .as_deref() | |
| 1115 | + .is_some_and(|s| !s.is_empty()), | |
| 1116 | + display_name: m.display_name.clone().unwrap_or_default(), | |
| 1117 | + domain: m.domain.clone(), | |
| 1118 | + last_seen: m.last_fetched_at.format("%Y-%m-%d %H:%M").to_string(), | |
| 1119 | + }) | |
| 1120 | + .collect(); | |
| 1121 | + let has_rows = !rows.is_empty(); | |
| 1122 | + | |
| 1123 | + let mut distinct_domains: Vec<String> = active_rows | |
| 1124 | + .iter() | |
| 1125 | + .map(|m| m.domain.clone()) | |
| 1126 | + .collect::<std::collections::BTreeSet<_>>() | |
| 1127 | + .into_iter() | |
| 1128 | + .collect(); | |
| 1129 | + distinct_domains.sort(); | |
| 1130 | + | |
| 1131 | + let t = |k: &str| { | |
| 1132 | + let i18n = i18n_service.clone(); | |
| 1133 | + let key = k.to_string(); | |
| 1134 | + let loc = locale_str.to_string(); | |
| 1135 | + async move { i18n.translate(&key, &loc).await } | |
| 1136 | + }; | |
| 1137 | + | |
| 1138 | + let template = FediverseSessionsRemoteTemplate { | |
| 1139 | + page_title: t("fediverse.sessions_remote.title").await, | |
| 1140 | + csrf_token, | |
| 1141 | + app_name: base_context.app_name, | |
| 1142 | + app_version: base_context.app_version, | |
| 1143 | + app_domain: base_context.app_domain, | |
| 1144 | + app_description: base_context.app_description, | |
| 1145 | + username: base_context.username, | |
| 1146 | + email: base_context.email, | |
| 1147 | + initials: base_context.initials, | |
| 1148 | + avatar_color: base_context.avatar_color, | |
| 1149 | + roles_text: base_context.roles.join(", "), | |
| 1150 | + has_pending_password_change: false, | |
| 1151 | + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 1152 | + .await, | |
| 1153 | + user_menu_profile: t("user_menu.profile").await, | |
| 1154 | + user_menu_logout: t("user_menu.logout").await, | |
| 1155 | + | |
| 1156 | + saved_flash: query.saved.unwrap_or(0) == 1, | |
| 1157 | + rows, | |
| 1158 | + has_rows, | |
| 1159 | + distinct_domains, | |
| 1160 | + | |
| 1161 | + heading: t("fediverse.sessions_remote.heading").await, | |
| 1162 | + subtitle: t("fediverse.sessions_remote.subtitle").await, | |
| 1163 | + kill_switch_legend: t("fediverse.sessions_remote.kill_switch_legend").await, | |
| 1164 | + kill_switch_label: t("fediverse.sessions_remote.kill_switch_label").await, | |
| 1165 | + kill_switch_button: t("fediverse.sessions_remote.kill_switch_button").await, | |
| 1166 | + kill_switch_confirm_title: t("fediverse.sessions_remote.kill_switch_confirm_title").await, | |
| 1167 | + kill_switch_confirm_body: t("fediverse.sessions_remote.kill_switch_confirm_body").await, | |
| 1168 | + table_handle: t("fediverse.sessions_remote.table_handle").await, | |
| 1169 | + table_display_name: t("fediverse.sessions_remote.table_display_name").await, | |
| 1170 | + table_domain: t("fediverse.sessions_remote.table_domain").await, | |
| 1171 | + table_last_seen: t("fediverse.sessions_remote.table_last_seen").await, | |
| 1172 | + table_actions: t("fediverse.sessions_remote.table_actions").await, | |
| 1173 | + revoke_button: t("fediverse.sessions_remote.revoke_button").await, | |
| 1174 | + revoke_confirm_title: t("fediverse.sessions_remote.revoke_confirm_title").await, | |
| 1175 | + revoke_confirm_body: t("fediverse.sessions_remote.revoke_confirm_body").await, | |
| 1176 | + modal_cancel: t("fediverse.sessions_remote.modal_cancel").await, | |
| 1177 | + modal_confirm: t("fediverse.sessions_remote.modal_confirm").await, | |
| 1178 | + empty_title: t("fediverse.sessions_remote.empty_title").await, | |
| 1179 | + empty_body: t("fediverse.sessions_remote.empty_body").await, | |
| 1180 | + saved_toast: t("fediverse.sessions_remote.saved_toast").await, | |
| 1181 | + breadcrumb_home: t("breadcrumb.home").await, | |
| 1182 | + breadcrumb_admin: t("breadcrumb.admin").await, | |
| 1183 | + breadcrumb_federation: t("fediverse.admin.hub.title").await, | |
| 1184 | + breadcrumb_self: t("fediverse.sessions_remote.breadcrumb").await, | |
| 1185 | + tabs: admin_tabs(&i18n_service, locale_str).await, | |
| 1186 | + active_tab: "sessions_remote".to_string(), | |
| 1187 | + }; | |
| 1188 | + | |
| 1189 | + Ok(template.into_response()) | |
| 1190 | +} | |
| 1191 | + | |
| 1192 | +/// `POST /admin/fediverse/sessions_remote/revoke` — set `disabled_at` | |
| 1193 | +/// on a single remote_identities row. | |
| 1194 | +pub async fn revoke_remote_session( | |
| 1195 | + State(db): State<DatabaseConnection>, | |
| 1196 | + session: Session, | |
| 1197 | + Form(form): Form<RevokeRemoteForm>, | |
| 1198 | +) -> Result<axum::response::Response, AppError> { | |
| 1199 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 1200 | + | |
| 1201 | + let trust = FediverseTrustService::new(db.clone()); | |
| 1202 | + if !trust.is_enabled().await? { | |
| 1203 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1204 | + } | |
| 1205 | + | |
| 1206 | + let id = uuid::Uuid::parse_str(&form.id) | |
| 1207 | + .map_err(|_| AppError::Validation("invalid remote identity id".into()))?; | |
| 1208 | + let now = chrono::Utc::now(); | |
| 1209 | + | |
| 1210 | + use sea_orm::sea_query::Expr; | |
| 1211 | + remote_identity::Entity::update_many() | |
| 1212 | + .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) | |
| 1213 | + .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) | |
| 1214 | + .filter(remote_identity::Column::Id.eq(id)) | |
| 1215 | + .exec(&db) | |
| 1216 | + .await?; | |
| 1217 | + | |
| 1218 | + Ok(Redirect::to("/admin/fediverse/sessions_remote?saved=1").into_response()) | |
| 1219 | +} | |
| 1220 | + | |
| 1221 | +/// `POST /admin/fediverse/sessions_remote/kill_domain` — bulk disable | |
| 1222 | +/// every remote_identities row from a given domain. Used as a panic | |
| 1223 | +/// button when an entire federated instance is compromised. | |
| 1224 | +pub async fn kill_domain( | |
| 1225 | + State(db): State<DatabaseConnection>, | |
| 1226 | + session: Session, | |
| 1227 | + Form(form): Form<KillDomainForm>, | |
| 1228 | +) -> Result<axum::response::Response, AppError> { | |
| 1229 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 1230 | + | |
| 1231 | + let trust = FediverseTrustService::new(db.clone()); | |
| 1232 | + if !trust.is_enabled().await? { | |
| 1233 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1234 | + } | |
| 1235 | + | |
| 1236 | + let domain = form.domain.trim().to_ascii_lowercase(); | |
| 1237 | + if domain.is_empty() { | |
| 1238 | + return Err(AppError::Validation("domain must not be empty".into())); | |
| 1239 | + } | |
| 1240 | + let now = chrono::Utc::now(); | |
| 1241 | + | |
| 1242 | + use sea_orm::sea_query::Expr; | |
| 1243 | + remote_identity::Entity::update_many() | |
| 1244 | + .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) | |
| 1245 | + .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) | |
| 1246 | + .filter(remote_identity::Column::Domain.eq(domain)) | |
| 1247 | + .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 1248 | + .exec(&db) | |
| 1249 | + .await?; | |
| 1250 | + | |
| 1251 | + Ok(Redirect::to("/admin/fediverse/sessions_remote?saved=1").into_response()) | |
| 1252 | +} | |
| 1253 | + | |
| 1254 | +// ───────────────────────────────────────────────────────────────────── | |
| 1255 | +// /admin/fediverse/nodeinfo (RFC 0024 Slice C iter. 3) | |
| 1256 | +// ───────────────────────────────────────────────────────────────────── | |
| 1257 | + | |
| 1258 | +/// Askama template for `GET /admin/fediverse/nodeinfo`. | |
| 1259 | +#[derive(Template)] | |
| 1260 | +#[template(path = "admin/nodeinfo.html")] | |
| 1261 | +#[allow(dead_code, missing_docs)] | |
| 1262 | +pub struct FediverseNodeInfoTemplate { | |
| 1263 | + pub page_title: String, | |
| 1264 | + pub csrf_token: String, | |
| 1265 | + pub app_name: String, | |
| 1266 | + pub app_version: String, | |
| 1267 | + pub app_domain: String, | |
| 1268 | + pub app_description: String, | |
| 1269 | + pub username: String, | |
| 1270 | + pub email: String, | |
| 1271 | + pub initials: String, | |
| 1272 | + pub avatar_color: String, | |
| 1273 | + pub roles_text: String, | |
| 1274 | + pub has_pending_password_change: bool, | |
| 1275 | + pub nav_items: Vec<NavItem>, | |
| 1276 | + pub user_menu_profile: String, | |
| 1277 | + pub user_menu_logout: String, | |
| 1278 | + | |
| 1279 | + // Software | |
| 1280 | + pub software_name: String, | |
| 1281 | + pub software_version: String, | |
| 1282 | + pub fediverse_domain: String, | |
| 1283 | + // Federation runtime config | |
| 1284 | + pub fediverse_enabled_label: String, | |
| 1285 | + pub trust_mode: String, | |
| 1286 | + pub interop_scope: String, | |
| 1287 | + pub authorized_fetch: bool, | |
| 1288 | + // User counts | |
| 1289 | + pub users_total: u64, | |
| 1290 | + pub users_active_month: u64, | |
| 1291 | + pub users_active_halfyear: u64, | |
| 1292 | + pub remote_identities_active: u64, | |
| 1293 | + pub remote_distinct_domains: u64, | |
| 1294 | + | |
| 1295 | + // i18n | |
| 1296 | + pub heading: String, | |
| 1297 | + pub subtitle: String, | |
| 1298 | + pub card_software: String, | |
| 1299 | + pub card_runtime: String, | |
| 1300 | + pub card_users: String, | |
| 1301 | + pub card_remotes: String, | |
| 1302 | + pub label_name: String, | |
| 1303 | + pub label_version: String, | |
| 1304 | + pub label_domain: String, | |
| 1305 | + pub label_enabled: String, | |
| 1306 | + pub label_trust_mode: String, | |
| 1307 | + pub label_interop_scope: String, | |
| 1308 | + pub label_authorized_fetch: String, | |
| 1309 | + pub label_users_total: String, | |
| 1310 | + pub label_users_active_month: String, | |
| 1311 | + pub label_users_active_halfyear: String, | |
| 1312 | + pub label_remote_identities_active: String, | |
| 1313 | + pub label_remote_distinct_domains: String, | |
| 1314 | + pub yes_label: String, | |
| 1315 | + pub no_label: String, | |
| 1316 | + pub breadcrumb_home: String, | |
| 1317 | + pub breadcrumb_admin: String, | |
| 1318 | + pub breadcrumb_federation: String, | |
| 1319 | + pub breadcrumb_self: String, | |
| 1320 | + pub tabs: Vec<AdminTabDTO>, | |
| 1321 | + pub active_tab: String, | |
| 1322 | +} | |
| 1323 | + | |
| 1324 | +rustwarden_core::impl_template_response!(FediverseNodeInfoTemplate); | |
| 1325 | + | |
| 1326 | +/// Render `GET /admin/fediverse/nodeinfo` — humanised stats panel. | |
| 1327 | +/// | |
| 1328 | +/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false`. | |
| 1329 | +pub async fn get_nodeinfo( | |
| 1330 | + Extension(claims): Extension<Claims>, | |
| 1331 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 1332 | + Extension(locale): Extension<Locale>, | |
| 1333 | + State(db): State<DatabaseConnection>, | |
| 1334 | + session: Session, | |
| 1335 | +) -> Result<axum::response::Response, AppError> { | |
| 1336 | + let trust = FediverseTrustService::new(db.clone()); | |
| 1337 | + if !trust.is_enabled().await? { | |
| 1338 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1339 | + } | |
| 1340 | + | |
| 1341 | + let locale_str = locale.as_str(); | |
| 1342 | + let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 1343 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 1344 | + | |
| 1345 | + // Federation runtime config (read straight from app_settings). | |
| 1346 | + let trust_mode = match trust.trust_mode().await? { | |
| 1347 | + TrustMode::Allowlist => "allowlist".to_string(), | |
| 1348 | + TrustMode::OpenWithBlocklist => "open_with_blocklist".to_string(), | |
| 1349 | + }; | |
| 1350 | + let interop_scope = AppSettingsService::get_setting(&db, "fediverse_interop_scope") | |
| 1351 | + .await? | |
| 1352 | + .map(|s| s.value) | |
| 1353 | + .unwrap_or_else(|| "rustwarden_only".to_string()); | |
| 1354 | + let authorized_fetch = | |
| 1355 | + AppSettingsService::get_bool(&db, "fediverse_authorized_fetch", false) | |
| 1356 | + .await?; | |
| 1357 | + | |
| 1358 | + // User counts (local users). | |
| 1359 | + let users_total = user_model::Entity::find().count(&db).await.unwrap_or(0); | |
| 1360 | + let now = chrono::Utc::now(); | |
| 1361 | + let users_active_month = user_model::Entity::find() | |
| 1362 | + .filter(user_model::Column::LastLoginAt.gte(now - chrono::Duration::days(30))) | |
| 1363 | + .count(&db) | |
| 1364 | + .await | |
| 1365 | + .unwrap_or(0); | |
| 1366 | + let users_active_halfyear = user_model::Entity::find() | |
| 1367 | + .filter(user_model::Column::LastLoginAt.gte(now - chrono::Duration::days(180))) | |
| 1368 | + .count(&db) | |
| 1369 | + .await | |
| 1370 | + .unwrap_or(0); | |
| 1371 | + | |
| 1372 | + // Remote identities (active = disabled_at IS NULL). | |
| 1373 | + let remote_identities_active = remote_identity::Entity::find() | |
| 1374 | + .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 1375 | + .count(&db) | |
| 1376 | + .await | |
| 1377 | + .unwrap_or(0); | |
| 1378 | + | |
| 1379 | + let remote_rows = remote_identity::Entity::find() | |
| 1380 | + .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 1381 | + .all(&db) | |
| 1382 | + .await | |
| 1383 | + .unwrap_or_default(); | |
| 1384 | + let remote_distinct_domains = remote_rows | |
| 1385 | + .iter() | |
| 1386 | + .map(|m| m.domain.clone()) | |
| 1387 | + .collect::<std::collections::BTreeSet<_>>() | |
| 1388 | + .len() as u64; | |
| 1389 | + | |
| 1390 | + let fediverse_domain = std::env::var("FEDIVERSE_DOMAIN").unwrap_or_else(|_| "—".to_string()); | |
| 1391 | + | |
| 1392 | + let t = |k: &str| { | |
| 1393 | + let i18n = i18n_service.clone(); | |
| 1394 | + let key = k.to_string(); | |
| 1395 | + let loc = locale_str.to_string(); | |
| 1396 | + async move { i18n.translate(&key, &loc).await } | |
| 1397 | + }; | |
| 1398 | + | |
| 1399 | + let template = FediverseNodeInfoTemplate { | |
| 1400 | + page_title: t("fediverse_nodeinfo.title").await, | |
| 1401 | + csrf_token, | |
| 1402 | + app_name: base_context.app_name, | |
| 1403 | + app_version: base_context.app_version, | |
| 1404 | + app_domain: base_context.app_domain, | |
| 1405 | + app_description: base_context.app_description, | |
| 1406 | + username: base_context.username, | |
| 1407 | + email: base_context.email, | |
| 1408 | + initials: base_context.initials, | |
| 1409 | + avatar_color: base_context.avatar_color, | |
| 1410 | + roles_text: base_context.roles.join(", "), | |
| 1411 | + has_pending_password_change: false, | |
| 1412 | + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 1413 | + .await, | |
| 1414 | + user_menu_profile: t("user_menu.profile").await, | |
| 1415 | + user_menu_logout: t("user_menu.logout").await, | |
| 1416 | + | |
| 1417 | + software_name: "rustwarden".to_string(), | |
| 1418 | + software_version: env!("CARGO_PKG_VERSION").to_string(), | |
| 1419 | + fediverse_domain, | |
| 1420 | + fediverse_enabled_label: t("common.yes").await, | |
| 1421 | + trust_mode, | |
| 1422 | + interop_scope, | |
| 1423 | + authorized_fetch, | |
| 1424 | + users_total, | |
| 1425 | + users_active_month, | |
| 1426 | + users_active_halfyear, | |
| 1427 | + remote_identities_active, | |
| 1428 | + remote_distinct_domains, | |
| 1429 | + | |
| 1430 | + heading: t("fediverse_nodeinfo.heading").await, | |
| 1431 | + subtitle: t("fediverse_nodeinfo.subtitle").await, | |
| 1432 | + card_software: t("fediverse_nodeinfo.card_software").await, | |
| 1433 | + card_runtime: t("fediverse_nodeinfo.card_runtime").await, | |
| 1434 | + card_users: t("fediverse_nodeinfo.card_users").await, | |
| 1435 | + card_remotes: t("fediverse_nodeinfo.card_remotes").await, | |
| 1436 | + label_name: t("fediverse_nodeinfo.label_name").await, | |
| 1437 | + label_version: t("fediverse_nodeinfo.label_version").await, | |
| 1438 | + label_domain: t("fediverse_nodeinfo.label_domain").await, | |
| 1439 | + label_enabled: t("fediverse_nodeinfo.label_enabled").await, | |
| 1440 | + label_trust_mode: t("fediverse_nodeinfo.label_trust_mode").await, | |
| 1441 | + label_interop_scope: t("fediverse_nodeinfo.label_interop_scope").await, | |
| 1442 | + label_authorized_fetch: t("fediverse_nodeinfo.label_authorized_fetch").await, | |
| 1443 | + label_users_total: t("fediverse_nodeinfo.label_users_total").await, | |
| 1444 | + label_users_active_month: t("fediverse_nodeinfo.label_users_active_month").await, | |
| 1445 | + label_users_active_halfyear: t("fediverse_nodeinfo.label_users_active_halfyear").await, | |
| 1446 | + label_remote_identities_active: t("fediverse_nodeinfo.label_remote_identities_active") | |
| 1447 | + .await, | |
| 1448 | + label_remote_distinct_domains: t("fediverse_nodeinfo.label_remote_distinct_domains").await, | |
| 1449 | + yes_label: t("common.yes").await, | |
| 1450 | + no_label: t("common.no").await, | |
| 1451 | + breadcrumb_home: t("breadcrumb.home").await, | |
| 1452 | + breadcrumb_admin: t("breadcrumb.admin").await, | |
| 1453 | + breadcrumb_federation: t("fediverse.admin.hub.title").await, | |
| 1454 | + breadcrumb_self: t("fediverse_nodeinfo.breadcrumb").await, | |
| 1455 | + tabs: admin_tabs(&i18n_service, locale_str).await, | |
| 1456 | + active_tab: "nodeinfo".to_string(), | |
| 1457 | + }; | |
| 1458 | + | |
| 1459 | + Ok(template.into_response()) | |
| 1460 | +} | |
| 1461 | + | |
| 1462 | +// ───────────────────────────────────────────────────────────────────── | |
| 1463 | +// /admin/fediverse/invites/{team_id} (RFC 0024 Slice C iter. 4) | |
| 1464 | +// ───────────────────────────────────────────────────────────────────── | |
| 1465 | + | |
| 1466 | +/// One row rendered into the team invites table. | |
| 1467 | +#[derive(Debug, Clone)] | |
| 1468 | +pub struct TeamInviteRowCtx { | |
| 1469 | + /// PK of the invite row. | |
| 1470 | + pub id: String, | |
| 1471 | + /// `@user@domain` handle. | |
| 1472 | + pub invited_handle: String, | |
| 1473 | + /// Wire form: `owner` / `admin` / `member`. | |
| 1474 | + pub role: String, | |
| 1475 | + /// Wire form: `pending` / `accepted` / `rejected` / `revoked` / `expired`. | |
| 1476 | + pub status: String, | |
| 1477 | + /// `created_at` formatted `YYYY-MM-DD HH:MM`. | |
| 1478 | + pub created_at: String, | |
| 1479 | +} | |
| 1480 | + | |
| 1481 | +/// Form payload for `POST /admin/fediverse/invites/{team_id}/add`. | |
| 1482 | +#[derive(Debug, Deserialize)] | |
| 1483 | +pub struct CreateTeamInviteForm { | |
| 1484 | + /// CSRF token from the hidden form field. | |
| 1485 | + pub csrf_token: String, | |
| 1486 | + /// `@user@domain` handle to invite. | |
| 1487 | + pub handle: String, | |
| 1488 | + /// `owner` / `admin` / `member`. | |
| 1489 | + pub role: String, | |
| 1490 | +} | |
| 1491 | + | |
| 1492 | +/// Form payload for `POST /admin/fediverse/invites/{team_id}/revoke`. | |
| 1493 | +#[derive(Debug, Deserialize)] | |
| 1494 | +pub struct RevokeInviteForm { | |
| 1495 | + /// CSRF token from the hidden form field. | |
| 1496 | + pub csrf_token: String, | |
| 1497 | + /// PK of the invite row to revoke. | |
| 1498 | + pub id: String, | |
| 1499 | +} | |
| 1500 | + | |
| 1501 | +/// Askama template for `GET /admin/fediverse/invites/{team_id}`. | |
| 1502 | +#[derive(Template)] | |
| 1503 | +#[template(path = "admin/team_invites.html")] | |
| 1504 | +#[allow(dead_code, missing_docs)] | |
| 1505 | +pub struct FediverseTeamInvitesTemplate { | |
| 1506 | + pub page_title: String, | |
| 1507 | + pub csrf_token: String, | |
| 1508 | + pub app_name: String, | |
| 1509 | + pub app_version: String, | |
| 1510 | + pub app_domain: String, | |
| 1511 | + pub app_description: String, | |
| 1512 | + pub username: String, | |
| 1513 | + pub email: String, | |
| 1514 | + pub initials: String, | |
| 1515 | + pub avatar_color: String, | |
| 1516 | + pub roles_text: String, | |
| 1517 | + pub has_pending_password_change: bool, | |
| 1518 | + pub nav_items: Vec<NavItem>, | |
| 1519 | + pub user_menu_profile: String, | |
| 1520 | + pub user_menu_logout: String, | |
| 1521 | + | |
| 1522 | + pub saved_flash: bool, | |
| 1523 | + /// Team name for the page heading. | |
| 1524 | + pub team_name: String, | |
| 1525 | + /// Team UUID (for form actions). | |
| 1526 | + pub team_id: String, | |
| 1527 | + pub rows: Vec<TeamInviteRowCtx>, | |
| 1528 | + pub has_rows: bool, | |
| 1529 | + | |
| 1530 | + pub heading: String, | |
| 1531 | + pub subtitle: String, | |
| 1532 | + pub add_form_legend: String, | |
| 1533 | + pub handle_label: String, | |
| 1534 | + pub handle_placeholder: String, | |
| 1535 | + pub role_label: String, | |
| 1536 | + pub role_member: String, | |
| 1537 | + pub role_admin: String, | |
| 1538 | + pub role_owner: String, | |
| 1539 | + pub add_button: String, | |
| 1540 | + pub table_handle: String, | |
| 1541 | + pub table_role: String, | |
| 1542 | + pub table_status: String, | |
| 1543 | + pub table_created_at: String, | |
| 1544 | + pub table_actions: String, | |
| 1545 | + pub revoke_button: String, | |
| 1546 | + pub revoke_confirm_title: String, | |
| 1547 | + pub revoke_confirm_body: String, | |
| 1548 | + pub modal_cancel: String, | |
| 1549 | + pub modal_confirm: String, | |
| 1550 | + pub empty_title: String, | |
| 1551 | + pub empty_body: String, | |
| 1552 | + pub saved_toast: String, | |
| 1553 | + pub breadcrumb_home: String, | |
| 1554 | + pub breadcrumb_admin: String, | |
| 1555 | + pub breadcrumb_federation: String, | |
| 1556 | + pub breadcrumb_self: String, | |
| 1557 | + pub tabs: Vec<AdminTabDTO>, | |
| 1558 | + pub active_tab: String, | |
| 1559 | +} | |
| 1560 | + | |
| 1561 | +rustwarden_core::impl_template_response!(FediverseTeamInvitesTemplate); | |
| 1562 | + | |
| 1563 | +/// Default TTL for team invitations (RFC 0009 §6). | |
| 1564 | +const INVITE_TTL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60); | |
| 1565 | + | |
| 1566 | +/// `GET /admin/fediverse/invites/{team_id}` — list pending invites. | |
| 1567 | +pub async fn get_team_invites( | |
| 1568 | + Extension(claims): Extension<Claims>, | |
| 1569 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 1570 | + Extension(locale): Extension<Locale>, | |
| 1571 | + State(db): State<DatabaseConnection>, | |
| 1572 | + session: Session, | |
| 1573 | + axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>, | |
| 1574 | + axum::extract::Query(query): axum::extract::Query<SavedQuery>, | |
| 1575 | +) -> Result<axum::response::Response, AppError> { | |
| 1576 | + let trust = FediverseTrustService::new(db.clone()); | |
| 1577 | + if !trust.is_enabled().await? { | |
| 1578 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1579 | + } | |
| 1580 | + | |
| 1581 | + // Verify team exists; otherwise 404. | |
| 1582 | + let Some(team_row) = team::Entity::find_by_id(team_id).one(&db).await? else { | |
| 1583 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Team not found").into_response()); | |
| 1584 | + }; | |
| 1585 | + | |
| 1586 | + let locale_str = locale.as_str(); | |
| 1587 | + let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 1588 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 1589 | + | |
| 1590 | + // Show all statuses for this team (not just pending) so the admin | |
| 1591 | + // sees the historical decisions. Order by created_at desc. | |
| 1592 | + let invite_rows = fediverse_pending_invite::Entity::find() | |
| 1593 | + .filter(fediverse_pending_invite::Column::TeamId.eq(team_id)) | |
| 1594 | + .order_by_desc(fediverse_pending_invite::Column::CreatedAt) | |
| 1595 | + .limit(100) | |
| 1596 | + .all(&db) | |
| 1597 | + .await?; | |
| 1598 | + | |
| 1599 | + let rows: Vec<TeamInviteRowCtx> = invite_rows | |
| 1600 | + .into_iter() | |
| 1601 | + .map(|m| TeamInviteRowCtx { | |
| 1602 | + id: m.id.to_string(), | |
| 1603 | + invited_handle: m.invited_handle, | |
| 1604 | + role: m.role, | |
| 1605 | + status: m.status, | |
| 1606 | + created_at: m.created_at.format("%Y-%m-%d %H:%M").to_string(), | |
| 1607 | + }) | |
| 1608 | + .collect(); | |
| 1609 | + let has_rows = !rows.is_empty(); | |
| 1610 | + | |
| 1611 | + let t = |k: &str| { | |
| 1612 | + let i18n = i18n_service.clone(); | |
| 1613 | + let key = k.to_string(); | |
| 1614 | + let loc = locale_str.to_string(); | |
| 1615 | + async move { i18n.translate(&key, &loc).await } | |
| 1616 | + }; | |
| 1617 | + | |
| 1618 | + let template = FediverseTeamInvitesTemplate { | |
| 1619 | + page_title: t("fediverse_invites.title").await, | |
| 1620 | + csrf_token, | |
| 1621 | + app_name: base_context.app_name, | |
| 1622 | + app_version: base_context.app_version, | |
| 1623 | + app_domain: base_context.app_domain, | |
| 1624 | + app_description: base_context.app_description, | |
| 1625 | + username: base_context.username, | |
| 1626 | + email: base_context.email, | |
| 1627 | + initials: base_context.initials, | |
| 1628 | + avatar_color: base_context.avatar_color, | |
| 1629 | + roles_text: base_context.roles.join(", "), | |
| 1630 | + has_pending_password_change: false, | |
| 1631 | + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 1632 | + .await, | |
| 1633 | + user_menu_profile: t("user_menu.profile").await, | |
| 1634 | + user_menu_logout: t("user_menu.logout").await, | |
| 1635 | + | |
| 1636 | + saved_flash: query.saved.unwrap_or(0) == 1, | |
| 1637 | + team_name: team_row.name, | |
| 1638 | + team_id: team_id.to_string(), | |
| 1639 | + rows, | |
| 1640 | + has_rows, | |
| 1641 | + | |
| 1642 | + heading: t("fediverse_invites.heading").await, | |
| 1643 | + subtitle: t("fediverse_invites.subtitle").await, | |
| 1644 | + add_form_legend: t("fediverse_invites.add_legend").await, | |
| 1645 | + handle_label: t("fediverse_invites.handle_label").await, | |
| 1646 | + handle_placeholder: t("fediverse_invites.handle_placeholder").await, | |
| 1647 | + role_label: t("fediverse_invites.role_label").await, | |
| 1648 | + role_member: t("fediverse_invites.role_member").await, | |
| 1649 | + role_admin: t("fediverse_invites.role_admin").await, | |
| 1650 | + role_owner: t("fediverse_invites.role_owner").await, | |
| 1651 | + add_button: t("fediverse_invites.add_button").await, | |
| 1652 | + table_handle: t("fediverse_invites.table_handle").await, | |
| 1653 | + table_role: t("fediverse_invites.table_role").await, | |
| 1654 | + table_status: t("fediverse_invites.table_status").await, | |
| 1655 | + table_created_at: t("fediverse_invites.table_created_at").await, | |
| 1656 | + table_actions: t("fediverse_invites.table_actions").await, | |
| 1657 | + revoke_button: t("fediverse_invites.revoke_button").await, | |
| 1658 | + revoke_confirm_title: t("fediverse_invites.revoke_confirm_title").await, | |
| 1659 | + revoke_confirm_body: t("fediverse_invites.revoke_confirm_body").await, | |
| 1660 | + modal_cancel: t("fediverse_invites.modal_cancel").await, | |
| 1661 | + modal_confirm: t("fediverse_invites.modal_confirm").await, | |
| 1662 | + empty_title: t("fediverse_invites.empty_title").await, | |
| 1663 | + empty_body: t("fediverse_invites.empty_body").await, | |
| 1664 | + saved_toast: t("fediverse_invites.saved_toast").await, | |
| 1665 | + breadcrumb_home: t("breadcrumb.home").await, | |
| 1666 | + breadcrumb_admin: t("breadcrumb.admin").await, | |
| 1667 | + breadcrumb_federation: t("fediverse.admin.hub.title").await, | |
| 1668 | + breadcrumb_self: t("fediverse_invites.breadcrumb").await, | |
| 1669 | + tabs: admin_tabs(&i18n_service, locale_str).await, | |
| 1670 | + active_tab: "invites".to_string(), | |
| 1671 | + }; | |
| 1672 | + | |
| 1673 | + Ok(template.into_response()) | |
| 1674 | +} | |
| 1675 | + | |
| 1676 | +/// `POST /admin/fediverse/invites/{team_id}/add` — create a pending invite. | |
| 1677 | +pub async fn create_team_invite( | |
| 1678 | + Extension(claims): Extension<Claims>, | |
| 1679 | + State(db): State<DatabaseConnection>, | |
| 1680 | + session: Session, | |
| 1681 | + axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>, | |
| 1682 | + Form(form): Form<CreateTeamInviteForm>, | |
| 1683 | +) -> Result<axum::response::Response, AppError> { | |
| 1684 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 1685 | + | |
| 1686 | + let trust = FediverseTrustService::new(db.clone()); | |
| 1687 | + if !trust.is_enabled().await? { | |
| 1688 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1689 | + } | |
| 1690 | + | |
| 1691 | + let handle = form.handle.trim(); | |
| 1692 | + if handle.is_empty() || !handle.contains('@') { | |
| 1693 | + return Err(AppError::Validation( | |
| 1694 | + "handle must be of form @user@domain".into(), | |
| 1695 | + )); | |
| 1696 | + } | |
| 1697 | + let role = TeamRole::parse(&form.role).ok_or_else(|| { | |
| 1698 | + AppError::Validation(format!("invalid role: {}", form.role)) | |
| 1699 | + })?; | |
| 1700 | + | |
| 1701 | + FediverseInviteService::invite_remote(&db, team_id, handle, role, claims.sub, INVITE_TTL) | |
| 1702 | + .await?; | |
| 1703 | + | |
| 1704 | + Ok(Redirect::to(&format!( | |
| 1705 | + "/admin/fediverse/invites/{team_id}?saved=1" | |
| 1706 | + )) | |
| 1707 | + .into_response()) | |
| 1708 | +} | |
| 1709 | + | |
| 1710 | +/// `POST /admin/fediverse/invites/{team_id}/revoke` — revoke an invite. | |
| 1711 | +pub async fn revoke_team_invite( | |
| 1712 | + State(db): State<DatabaseConnection>, | |
| 1713 | + session: Session, | |
| 1714 | + axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>, | |
| 1715 | + Form(form): Form<RevokeInviteForm>, | |
| 1716 | +) -> Result<axum::response::Response, AppError> { | |
| 1717 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 1718 | + | |
| 1719 | + let trust = FediverseTrustService::new(db.clone()); | |
| 1720 | + if !trust.is_enabled().await? { | |
| 1721 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1722 | + } | |
| 1723 | + | |
| 1724 | + let invite_id = uuid::Uuid::parse_str(&form.id) | |
| 1725 | + .map_err(|_| AppError::Validation("invalid invite id".into()))?; | |
| 1726 | + | |
| 1727 | + // Touch unused-import guard for InviteStatus (kept exported). | |
| 1728 | + let _ = InviteStatus::Pending; | |
| 1729 | + | |
| 1730 | + FediverseInviteService::revoke(&db, invite_id).await?; | |
| 1731 | + | |
| 1732 | + Ok(Redirect::to(&format!( | |
| 1733 | + "/admin/fediverse/invites/{team_id}?saved=1" | |
| 1734 | + )) | |
| 1735 | + .into_response()) | |
| 1736 | +} | |
| 1737 | + | |
| 1738 | +// ───────────────────────────────────────────────────────────────────── | |
| 1739 | +// /admin/fediverse — federation admin hub | |
| 1740 | +// ───────────────────────────────────────────────────────────────────── | |
| 1741 | + | |
| 1742 | +/// One card rendered into the federation admin hub. | |
| 1743 | +#[derive(Debug, Clone)] | |
| 1744 | +pub struct HubCardCtx { | |
| 1745 | + /// Translated card title. | |
| 1746 | + pub title: String, | |
| 1747 | + /// Translated short description. | |
| 1748 | + pub desc: String, | |
| 1749 | + /// Absolute URL the card links to. | |
| 1750 | + pub href: String, | |
| 1751 | + /// Unicode glyph rendered as a leading icon. | |
| 1752 | + pub icon: String, | |
| 1753 | +} | |
| 1754 | + | |
| 1755 | +/// Askama template for `GET /admin/fediverse` (the federation hub). | |
| 1756 | +#[derive(Template)] | |
| 1757 | +#[template(path = "admin/hub.html")] | |
| 1758 | +#[allow(dead_code, missing_docs)] | |
| 1759 | +pub struct FediverseHubTemplate { | |
| 1760 | + pub page_title: String, | |
| 1761 | + pub csrf_token: String, | |
| 1762 | + pub app_name: String, | |
| 1763 | + pub app_version: String, | |
| 1764 | + pub app_domain: String, | |
| 1765 | + pub app_description: String, | |
| 1766 | + pub username: String, | |
| 1767 | + pub email: String, | |
| 1768 | + pub initials: String, | |
| 1769 | + pub avatar_color: String, | |
| 1770 | + pub roles_text: String, | |
| 1771 | + pub has_pending_password_change: bool, | |
| 1772 | + pub nav_items: Vec<NavItem>, | |
| 1773 | + pub user_menu_profile: String, | |
| 1774 | + pub user_menu_logout: String, | |
| 1775 | + | |
| 1776 | + pub heading: String, | |
| 1777 | + pub subtitle: String, | |
| 1778 | + pub cards: Vec<HubCardCtx>, | |
| 1779 | + pub breadcrumb_home: String, | |
| 1780 | + pub breadcrumb_admin: String, | |
| 1781 | + pub breadcrumb_self: String, | |
| 1782 | +} | |
| 1783 | + | |
| 1784 | +rustwarden_core::impl_template_response!(FediverseHubTemplate); | |
| 1785 | + | |
| 1786 | +/// Render `GET /admin/fediverse` — entry point for every admin | |
| 1787 | +/// sub-section. Lists the six federation cards (settings, trust list, | |
| 1788 | +/// queue, remote sessions, nodeinfo, invites) so the admin can pick | |
| 1789 | +/// where to land instead of memorising URLs. | |
| 1790 | +/// | |
| 1791 | +/// Unlike the rest of `/admin/fediverse/*`, the hub is reachable even | |
| 1792 | +/// when `fediverse_enabled = false` so the admin can navigate to the | |
| 1793 | +/// settings page and turn the feature on. | |
| 1794 | +pub async fn get_hub( | |
| 1795 | + Extension(claims): Extension<Claims>, | |
| 1796 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 1797 | + Extension(locale): Extension<Locale>, | |
| 1798 | + State(db): State<DatabaseConnection>, | |
| 1799 | + session: Session, | |
| 1800 | +) -> Result<impl IntoResponse, AppError> { | |
| 1801 | + let locale_str = locale.as_str(); | |
| 1802 | + let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 1803 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 1804 | + | |
| 1805 | + let t = |k: &str| { | |
| 1806 | + let i18n = i18n_service.clone(); | |
| 1807 | + let key = k.to_string(); | |
| 1808 | + let loc = locale_str.to_string(); | |
| 1809 | + async move { i18n.translate(&key, &loc).await } | |
| 1810 | + }; | |
| 1811 | + | |
| 1812 | + let card_specs: &[(&str, &str, &str, &str)] = &[ | |
| 1813 | + ( | |
| 1814 | + "settings", | |
| 1815 | + "/admin/fediverse/settings", | |
| 1816 | + "fediverse.admin.hub.card.settings.title", | |
| 1817 | + "fediverse.admin.hub.card.settings.desc", | |
| 1818 | + ), | |
| 1819 | + ( | |
| 1820 | + "trust_list", | |
| 1821 | + "/admin/fediverse/trust_list", | |
| 1822 | + "fediverse.admin.hub.card.trust_list.title", | |
| 1823 | + "fediverse.admin.hub.card.trust_list.desc", | |
| 1824 | + ), | |
| 1825 | + ( | |
| 1826 | + "queue", | |
| 1827 | + "/admin/fediverse/queue", | |
| 1828 | + "fediverse.admin.hub.card.queue.title", | |
| 1829 | + "fediverse.admin.hub.card.queue.desc", | |
| 1830 | + ), | |
| 1831 | + ( | |
| 1832 | + "sessions_remote", | |
| 1833 | + "/admin/fediverse/sessions_remote", | |
| 1834 | + "fediverse.admin.hub.card.sessions_remote.title", | |
| 1835 | + "fediverse.admin.hub.card.sessions_remote.desc", | |
| 1836 | + ), | |
| 1837 | + ( | |
| 1838 | + "nodeinfo", | |
| 1839 | + "/admin/fediverse/nodeinfo", | |
| 1840 | + "fediverse.admin.hub.card.nodeinfo.title", | |
| 1841 | + "fediverse.admin.hub.card.nodeinfo.desc", | |
| 1842 | + ), | |
| 1843 | + ( | |
| 1844 | + "invites", | |
| 1845 | + "/admin/fediverse/teams", | |
| 1846 | + "fediverse.admin.hub.card.invites.title", | |
| 1847 | + "fediverse.admin.hub.card.invites.desc", | |
| 1848 | + ), | |
| 1849 | + ]; | |
| 1850 | + | |
| 1851 | + let icons: &[&str] = &["⚙️", "🛡️", "📤", "🌐", "📊", "✉️"]; | |
| 1852 | + let mut cards = Vec::with_capacity(card_specs.len()); | |
| 1853 | + for ((_, href, tk, dk), icon) in card_specs.iter().zip(icons.iter()) { | |
| 1854 | + cards.push(HubCardCtx { | |
| 1855 | + title: t(tk).await, | |
| 1856 | + desc: t(dk).await, | |
| 1857 | + href: (*href).to_string(), | |
| 1858 | + icon: (*icon).to_string(), | |
| 1859 | + }); | |
| 1860 | + } | |
| 1861 | + | |
| 1862 | + let template = FediverseHubTemplate { | |
| 1863 | + page_title: t("fediverse.admin.hub.title").await, | |
| 1864 | + csrf_token, | |
| 1865 | + app_name: base_context.app_name, | |
| 1866 | + app_version: base_context.app_version, | |
| 1867 | + app_domain: base_context.app_domain, | |
| 1868 | + app_description: base_context.app_description, | |
| 1869 | + username: base_context.username, | |
| 1870 | + email: base_context.email, | |
| 1871 | + initials: base_context.initials, | |
| 1872 | + avatar_color: base_context.avatar_color, | |
| 1873 | + roles_text: base_context.roles.join(", "), | |
| 1874 | + has_pending_password_change: false, | |
| 1875 | + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 1876 | + .await, | |
| 1877 | + user_menu_profile: t("user_menu.profile").await, | |
| 1878 | + user_menu_logout: t("user_menu.logout").await, | |
| 1879 | + | |
| 1880 | + heading: t("fediverse.admin.hub.title").await, | |
| 1881 | + subtitle: t("fediverse.admin.hub.subtitle").await, | |
| 1882 | + cards, | |
| 1883 | + breadcrumb_home: t("breadcrumb.home").await, | |
| 1884 | + breadcrumb_admin: t("breadcrumb.admin").await, | |
| 1885 | + breadcrumb_self: t("fediverse.admin.hub.title").await, | |
| 1886 | + }; | |
| 1887 | + | |
| 1888 | + Ok(template) | |
| 1889 | +} | |
| 1890 | + | |
| 1891 | +// ───────────────────────────────────────────────────────────────────── | |
| 1892 | +// /admin/fediverse/teams — picker for the per-team invites screen | |
| 1893 | +// ───────────────────────────────────────────────────────────────────── | |
| 1894 | + | |
| 1895 | +/// One row rendered into the team picker for invites. | |
| 1896 | +#[derive(Debug, Clone)] | |
| 1897 | +pub struct TeamRowCtx { | |
| 1898 | + /// Team UUID used in the per-team invites URL. | |
| 1899 | + pub id: String, | |
| 1900 | + /// Team display name. | |
| 1901 | + pub name: String, | |
| 1902 | + /// Team URL slug (rendered for disambiguation). | |
| 1903 | + pub slug: String, | |
| 1904 | +} | |
| 1905 | + | |
| 1906 | +/// Askama template for `GET /admin/fediverse/teams`. | |
| 1907 | +#[derive(Template)] | |
| 1908 | +#[template(path = "admin/teams_for_invites.html")] | |
| 1909 | +#[allow(dead_code, missing_docs)] | |
| 1910 | +pub struct FediverseTeamsForInvitesTemplate { | |
| 1911 | + pub page_title: String, | |
| 1912 | + pub csrf_token: String, | |
| 1913 | + pub app_name: String, | |
| 1914 | + pub app_version: String, | |
| 1915 | + pub app_domain: String, | |
| 1916 | + pub app_description: String, | |
| 1917 | + pub username: String, | |
| 1918 | + pub email: String, | |
| 1919 | + pub initials: String, | |
| 1920 | + pub avatar_color: String, | |
| 1921 | + pub roles_text: String, | |
| 1922 | + pub has_pending_password_change: bool, | |
| 1923 | + pub nav_items: Vec<NavItem>, | |
| 1924 | + pub user_menu_profile: String, | |
| 1925 | + pub user_menu_logout: String, | |
| 1926 | + | |
| 1927 | + pub heading: String, | |
| 1928 | + pub subtitle: String, | |
| 1929 | + pub teams: Vec<TeamRowCtx>, | |
| 1930 | + pub has_teams: bool, | |
| 1931 | + pub empty_title: String, | |
| 1932 | + pub empty_body: String, | |
| 1933 | + pub breadcrumb_home: String, | |
| 1934 | + pub breadcrumb_admin: String, | |
| 1935 | + pub breadcrumb_federation: String, | |
| 1936 | + pub breadcrumb_self: String, | |
| 1937 | + pub tabs: Vec<AdminTabDTO>, | |
| 1938 | + pub active_tab: String, | |
| 1939 | +} | |
| 1940 | + | |
| 1941 | +rustwarden_core::impl_template_response!(FediverseTeamsForInvitesTemplate); | |
| 1942 | + | |
| 1943 | +/// Render `GET /admin/fediverse/teams` — list every team so the admin | |
| 1944 | +/// can pick one before reaching `/admin/fediverse/invites/{team_id}`. | |
| 1945 | +/// Lives at the federation hub level because no global "all invites" | |
| 1946 | +/// view exists today. | |
| 1947 | +pub async fn get_teams_for_invites( | |
| 1948 | + Extension(claims): Extension<Claims>, | |
| 1949 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 1950 | + Extension(locale): Extension<Locale>, | |
| 1951 | + State(db): State<DatabaseConnection>, | |
| 1952 | + session: Session, | |
| 1953 | +) -> Result<axum::response::Response, AppError> { | |
| 1954 | + let trust = FediverseTrustService::new(db.clone()); | |
| 1955 | + if !trust.is_enabled().await? { | |
| 1956 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1957 | + } | |
| 1958 | + | |
| 1959 | + let locale_str = locale.as_str(); | |
| 1960 | + let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 1961 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 1962 | + | |
| 1963 | + let team_rows = team::Entity::find() | |
| 1964 | + .order_by_asc(team::Column::Name) | |
| 1965 | + .all(&db) | |
| 1966 | + .await?; | |
| 1967 | + | |
| 1968 | + let teams: Vec<TeamRowCtx> = team_rows | |
| 1969 | + .into_iter() | |
| 1970 | + .map(|t| TeamRowCtx { | |
| 1971 | + id: t.id.to_string(), | |
| 1972 | + name: t.name, | |
| 1973 | + slug: t.slug, | |
| 1974 | + }) | |
| 1975 | + .collect(); | |
| 1976 | + let has_teams = !teams.is_empty(); | |
| 1977 | + | |
| 1978 | + let t = |k: &str| { | |
| 1979 | + let i18n = i18n_service.clone(); | |
| 1980 | + let key = k.to_string(); | |
| 1981 | + let loc = locale_str.to_string(); | |
| 1982 | + async move { i18n.translate(&key, &loc).await } | |
| 1983 | + }; | |
| 1984 | + | |
| 1985 | + let template = FediverseTeamsForInvitesTemplate { | |
| 1986 | + page_title: t("fediverse.admin.teams_for_invites.title").await, | |
| 1987 | + csrf_token, | |
| 1988 | + app_name: base_context.app_name, | |
| 1989 | + app_version: base_context.app_version, | |
| 1990 | + app_domain: base_context.app_domain, | |
| 1991 | + app_description: base_context.app_description, | |
| 1992 | + username: base_context.username, | |
| 1993 | + email: base_context.email, | |
| 1994 | + initials: base_context.initials, | |
| 1995 | + avatar_color: base_context.avatar_color, | |
| 1996 | + roles_text: base_context.roles.join(", "), | |
| 1997 | + has_pending_password_change: false, | |
| 1998 | + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 1999 | + .await, | |
| 2000 | + user_menu_profile: t("user_menu.profile").await, | |
| 2001 | + user_menu_logout: t("user_menu.logout").await, | |
| 2002 | + | |
| 2003 | + heading: t("fediverse.admin.teams_for_invites.heading").await, | |
| 2004 | + subtitle: t("fediverse.admin.teams_for_invites.subtitle").await, | |
| 2005 | + teams, | |
| 2006 | + has_teams, | |
| 2007 | + empty_title: t("fediverse.admin.teams_for_invites.empty_title").await, | |
| 2008 | + empty_body: t("fediverse.admin.teams_for_invites.empty_body").await, | |
| 2009 | + breadcrumb_home: t("breadcrumb.home").await, | |
| 2010 | + breadcrumb_admin: t("breadcrumb.admin").await, | |
| 2011 | + breadcrumb_federation: t("fediverse.admin.hub.title").await, | |
| 2012 | + breadcrumb_self: t("fediverse.admin.teams_for_invites.breadcrumb").await, | |
| 2013 | + tabs: admin_tabs(&i18n_service, locale_str).await, | |
| 2014 | + active_tab: "invites".to_string(), | |
| 2015 | + }; | |
| 2016 | + | |
| 2017 | + Ok(template.into_response()) | |
| 2018 | +} |
A
crates/rustwarden-fediverse-ui/src/handlers/mod.rs
+8
-0
@@ -0,0 +1,8 @@
| 1 | +//! HTTP handlers for the federation UI. | |
| 2 | + | |
| 3 | +/// Admin handlers (`/admin/fediverse/*`): settings, trust list, queue, | |
| 4 | +/// remote sessions, node info, team invites. | |
| 5 | +pub mod admin; | |
| 6 | +/// User-facing handlers: login form, waiting page, SSE status, consent | |
| 7 | +/// screen, invite landing, connected apps. | |
| 8 | +pub mod user_auth; |
A
crates/rustwarden-fediverse-ui/src/handlers/user_auth.rs
+1053
-0
@@ -0,0 +1,1053 @@
| 1 | +//! User-facing handlers for the Fediverse auth flow (RFC 0023, Slice B). | |
| 2 | +//! | |
| 3 | +//! Iter. 1 ships only the error landing page | |
| 4 | +//! (`/login/fediverse/error?reason=…`) — the four other endpoints | |
| 5 | +//! (login form, waiting page, SSE, consent) land in subsequent iters. | |
| 6 | +//! | |
| 7 | +//! All routes here are **public** (no `Claims` extension required) and | |
| 8 | +//! gated by `experimental-fediverse`. The mode-dégradé (Plan UI §3 P9) | |
| 9 | +//! returns `404` when `fediverse_enabled = false`. | |
| 10 | + | |
| 11 | +use std::convert::Infallible; | |
| 12 | +use std::sync::Arc; | |
| 13 | +use std::time::Duration; | |
| 14 | + | |
| 15 | +use askama::Template; | |
| 16 | +use axum::extract::{Extension, Form, Path, Query, State}; | |
| 17 | +use axum::response::sse::{Event, KeepAlive, Sse}; | |
| 18 | +use axum::response::{IntoResponse, Redirect}; | |
| 19 | +use futures::stream::Stream; | |
| 20 | +use uuid::Uuid; | |
| 21 | +use rustwarden_core::middleware::csrf; | |
| 22 | +use sea_orm::DatabaseConnection; | |
| 23 | +use serde::Deserialize; | |
| 24 | +use tower_sessions::Session; | |
| 25 | + | |
| 26 | +use rustwarden_core::error::AppError; | |
| 27 | +use rustwarden_core::i18n::extractor::Locale; | |
| 28 | +use rustwarden_core::i18n::service::I18nService; | |
| 29 | +use rustwarden_core::auth::jwt::Claims; | |
| 30 | +use rustwarden_fediverse::services::fediverse_auth_service::{ | |
| 31 | + build_auth_decision_payload, ChallengeDecision, ChallengeStatus, FediverseAuthService, | |
| 32 | + InitiateRequest, | |
| 33 | +}; | |
| 34 | +use rustwarden_fediverse::services::fediverse_invite_service::FediverseInviteService; | |
| 35 | +use rustwarden_fediverse::models::fediverse_pending_invite; | |
| 36 | +use rustwarden_fediverse::services::fediverse_trust_service::FediverseTrustService; | |
| 37 | +use rustwarden_fediverse::services::fediverse_outbound_service::{ | |
| 38 | + EnqueueRequest, FediverseOutboundService, | |
| 39 | +}; | |
| 40 | +use rustwarden_fediverse::FediverseConfig; | |
| 41 | +use rustwarden_fediverse::models::remote_identity; | |
| 42 | + | |
| 43 | +use rustwarden_fediverse::models::fediverse_auth_challenge; | |
| 44 | +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; | |
| 45 | + | |
| 46 | +/// Default challenge TTL for the AP-native auth flow (RFC 0006 §6). | |
| 47 | +/// Operator override via `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` is honoured | |
| 48 | +/// by the service layer separately; here we only provide a UI default | |
| 49 | +/// to hand into [`FediverseAuthService::initiate`]. | |
| 50 | +const CHALLENGE_TTL: Duration = Duration::from_secs(5 * 60); | |
| 51 | + | |
| 52 | +/// Whitelist of accepted `?reason=` values. Any other input falls back | |
| 53 | +/// to [`ErrorReason::Unknown`] which renders a generic message — keeps | |
| 54 | +/// the URL safe to share without leaking internal status names. | |
| 55 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 56 | +pub enum ErrorReason { | |
| 57 | + /// `Accept{AuthRequest}` not received within the challenge TTL. | |
| 58 | + Timeout, | |
| 59 | + /// IdP explicitly rejected the auth request. | |
| 60 | + Rejected, | |
| 61 | + /// Domain not in the allowlist (or in the blocklist). | |
| 62 | + Untrusted, | |
| 63 | + /// HTTP signature on the IdP response did not verify. | |
| 64 | + InvalidSignature, | |
| 65 | + /// Anything else (renders the generic catch-all message). | |
| 66 | + Unknown, | |
| 67 | +} | |
| 68 | + | |
| 69 | +impl ErrorReason { | |
| 70 | + /// Parse the `?reason=` query value into an enum. Trims, lowercases. | |
| 71 | + pub fn parse(raw: &str) -> Self { | |
| 72 | + match raw.trim().to_ascii_lowercase().as_str() { | |
| 73 | + "timeout" => Self::Timeout, | |
| 74 | + "rejected" => Self::Rejected, | |
| 75 | + "untrusted" => Self::Untrusted, | |
| 76 | + "invalid_signature" | "invalid_sig" => Self::InvalidSignature, | |
| 77 | + _ => Self::Unknown, | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + /// i18n key suffix for the heading + body copy. | |
| 82 | + pub fn i18n_key(self) -> &'static str { | |
| 83 | + match self { | |
| 84 | + Self::Timeout => "timeout", | |
| 85 | + Self::Rejected => "rejected", | |
| 86 | + Self::Untrusted => "untrusted", | |
| 87 | + Self::InvalidSignature => "invalid_signature", | |
| 88 | + Self::Unknown => "unknown", | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + /// Stable wire form (echoed back into the template for icon styling). | |
| 93 | + pub fn as_str(self) -> &'static str { | |
| 94 | + self.i18n_key() | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// `?reason=` query parameter for `GET /login/fediverse/error`. | |
| 99 | +#[derive(Debug, Deserialize)] | |
| 100 | +pub struct ErrorQuery { | |
| 101 | + /// One of `timeout`, `rejected`, `untrusted`, `invalid_signature`. | |
| 102 | + /// Anything else renders the generic message. | |
| 103 | + pub reason: Option<String>, | |
| 104 | +} | |
| 105 | + | |
| 106 | +/// Askama template for `GET /login/fediverse/error`. | |
| 107 | +#[derive(Template)] | |
| 108 | +#[template(path = "error_unauthorized.html")] | |
| 109 | +#[allow(dead_code)] | |
| 110 | +pub struct FediverseAuthErrorTemplate { | |
| 111 | + /// IETF locale tag (`fr`, `en`, …) for `<html lang>`. | |
| 112 | + pub current_locale: String, | |
| 113 | + /// Browser tab title. | |
| 114 | + pub page_title: String, | |
| 115 | + /// Meta-description copy. | |
| 116 | + pub subtitle: String, | |
| 117 | + /// Wire form of the error reason — used by the template to pick an | |
| 118 | + /// icon (timeout / invalid_signature / generic alert). | |
| 119 | + pub reason: String, | |
| 120 | + /// i18n-translated H1. | |
| 121 | + pub heading: String, | |
| 122 | + /// i18n-translated body paragraph. | |
| 123 | + pub body: String, | |
| 124 | + /// Primary CTA label. | |
| 125 | + pub back_to_login: String, | |
| 126 | +} | |
| 127 | + | |
| 128 | +rustwarden_core::impl_template_response!(FediverseAuthErrorTemplate); | |
| 129 | + | |
| 130 | +/// `GET /login/fediverse/error?reason=…` — landing page after a failed | |
| 131 | +/// auth attempt. | |
| 132 | +/// | |
| 133 | +/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` so | |
| 134 | +/// the page is invisible while the feature is off. | |
| 135 | +pub async fn error( | |
| 136 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 137 | + Extension(locale): Extension<Locale>, | |
| 138 | + State(db): State<DatabaseConnection>, | |
| 139 | + Query(query): Query<ErrorQuery>, | |
| 140 | +) -> Result<axum::response::Response, AppError> { | |
| 141 | + let trust = FediverseTrustService::new(db.clone()); | |
| 142 | + if !trust.is_enabled().await? { | |
| 143 | + return Ok(( | |
| 144 | + axum::http::StatusCode::NOT_FOUND, | |
| 145 | + "Federation disabled", | |
| 146 | + ) | |
| 147 | + .into_response()); | |
| 148 | + } | |
| 149 | + | |
| 150 | + let reason = ErrorReason::parse(query.reason.as_deref().unwrap_or("")); | |
| 151 | + let locale_str = locale.as_str(); | |
| 152 | + let key = reason.i18n_key(); | |
| 153 | + | |
| 154 | + let heading_key = format!("fediverse.auth.error.{key}.title"); | |
| 155 | + let body_key = format!("fediverse.auth.error.{key}.body"); | |
| 156 | + | |
| 157 | + let template = FediverseAuthErrorTemplate { | |
| 158 | + current_locale: locale_str.to_string(), | |
| 159 | + page_title: i18n_service | |
| 160 | + .translate("fediverse.auth.error.page_title", locale_str) | |
| 161 | + .await, | |
| 162 | + subtitle: i18n_service | |
| 163 | + .translate("fediverse.auth.error.subtitle", locale_str) | |
| 164 | + .await, | |
| 165 | + reason: reason.as_str().to_string(), | |
| 166 | + heading: i18n_service.translate(&heading_key, locale_str).await, | |
| 167 | + body: i18n_service.translate(&body_key, locale_str).await, | |
| 168 | + back_to_login: i18n_service | |
| 169 | + .translate("fediverse.auth.error.back_to_login", locale_str) | |
| 170 | + .await, | |
| 171 | + }; | |
| 172 | + | |
| 173 | + Ok(template.into_response()) | |
| 174 | +} | |
| 175 | + | |
| 176 | +// ───────────────────────────────────────────────────────────────────── | |
| 177 | +// /login/fediverse — login form + initiate (RFC 0023 Slice B iter. 2) | |
| 178 | +// ───────────────────────────────────────────────────────────────────── | |
| 179 | + | |
| 180 | +/// Form payload for `POST /login/fediverse`. | |
| 181 | +#[derive(Debug, Deserialize)] | |
| 182 | +pub struct LoginInitiateForm { | |
| 183 | + /// CSRF token from the hidden form field. | |
| 184 | + pub csrf_token: String, | |
| 185 | + /// `@user@domain` handle the user typed. | |
| 186 | + pub handle: String, | |
| 187 | +} | |
| 188 | + | |
| 189 | +/// Askama template for `GET /login/fediverse`. | |
| 190 | +#[derive(Template)] | |
| 191 | +#[template(path = "login_form.html")] | |
| 192 | +#[allow(dead_code, missing_docs)] | |
| 193 | +pub struct FediverseLoginFormTemplate { | |
| 194 | + pub current_locale: String, | |
| 195 | + pub page_title: String, | |
| 196 | + pub subtitle: String, | |
| 197 | + pub csrf_token: String, | |
| 198 | + pub heading: String, | |
| 199 | + pub field_label: String, | |
| 200 | + pub field_placeholder: String, | |
| 201 | + pub field_help: String, | |
| 202 | + pub submit_button: String, | |
| 203 | + pub back_to_login: String, | |
| 204 | +} | |
| 205 | + | |
| 206 | +rustwarden_core::impl_template_response!(FediverseLoginFormTemplate); | |
| 207 | + | |
| 208 | +/// `GET /login/fediverse` — render the AP-native login form. | |
| 209 | +/// | |
| 210 | +/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false`. | |
| 211 | +pub async fn login_form( | |
| 212 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 213 | + Extension(locale): Extension<Locale>, | |
| 214 | + State(db): State<DatabaseConnection>, | |
| 215 | + session: Session, | |
| 216 | +) -> Result<axum::response::Response, AppError> { | |
| 217 | + let trust = FediverseTrustService::new(db.clone()); | |
| 218 | + if !trust.is_enabled().await? { | |
| 219 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 220 | + } | |
| 221 | + | |
| 222 | + let locale_str = locale.as_str(); | |
| 223 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 224 | + | |
| 225 | + let template = FediverseLoginFormTemplate { | |
| 226 | + current_locale: locale_str.to_string(), | |
| 227 | + page_title: i18n_service | |
| 228 | + .translate("fediverse.auth.login.page_title", locale_str) | |
| 229 | + .await, | |
| 230 | + subtitle: i18n_service | |
| 231 | + .translate("fediverse.auth.login.subtitle", locale_str) | |
| 232 | + .await, | |
| 233 | + csrf_token, | |
| 234 | + heading: i18n_service | |
| 235 | + .translate("fediverse.auth.login.heading", locale_str) | |
| 236 | + .await, | |
| 237 | + field_label: i18n_service | |
| 238 | + .translate("fediverse.auth.login.field_label", locale_str) | |
| 239 | + .await, | |
| 240 | + field_placeholder: i18n_service | |
| 241 | + .translate("fediverse.auth.login.field_placeholder", locale_str) | |
| 242 | + .await, | |
| 243 | + field_help: i18n_service | |
| 244 | + .translate("fediverse.auth.login.field_help", locale_str) | |
| 245 | + .await, | |
| 246 | + submit_button: i18n_service | |
| 247 | + .translate("fediverse.auth.login.submit", locale_str) | |
| 248 | + .await, | |
| 249 | + back_to_login: i18n_service | |
| 250 | + .translate("fediverse.auth.login.back_to_login", locale_str) | |
| 251 | + .await, | |
| 252 | + }; | |
| 253 | + | |
| 254 | + Ok(template.into_response()) | |
| 255 | +} | |
| 256 | + | |
| 257 | +/// Extract the `domain` part from a `@user@domain` (or `user@domain`) | |
| 258 | +/// handle. Returns `None` on malformed input. Lowercased. | |
| 259 | +pub fn extract_handle_domain(handle: &str) -> Option<String> { | |
| 260 | + let trimmed = handle.trim().trim_start_matches('@'); | |
| 261 | + let (_, domain) = trimmed.split_once('@')?; | |
| 262 | + let domain = domain.trim(); | |
| 263 | + if domain.is_empty() { | |
| 264 | + return None; | |
| 265 | + } | |
| 266 | + Some(domain.to_ascii_lowercase()) | |
| 267 | +} | |
| 268 | + | |
| 269 | +/// `POST /login/fediverse` — start an AP-native login. | |
| 270 | +/// | |
| 271 | +/// Pipeline: | |
| 272 | +/// 1. Validate CSRF. | |
| 273 | +/// 2. Reject if `fediverse_enabled = false` (404). | |
| 274 | +/// 3. Parse handle ⇒ domain ; reject malformed via `error?reason=unknown`. | |
| 275 | +/// 4. Check `FediverseTrustService::is_domain_allowed(domain)` ; | |
| 276 | +/// redirect to `error?reason=untrusted` if denied. | |
| 277 | +/// 5. Call `FediverseAuthService::initiate` ; on `Ok` redirect to | |
| 278 | +/// `/login/fediverse/waiting/{id}` (page delivered iter. 3). | |
| 279 | +/// 6. On any other error, redirect to `error?reason=unknown`. | |
| 280 | +pub async fn login_initiate( | |
| 281 | + State(db): State<DatabaseConnection>, | |
| 282 | + session: Session, | |
| 283 | + Form(form): Form<LoginInitiateForm>, | |
| 284 | +) -> Result<axum::response::Response, AppError> { | |
| 285 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 286 | + | |
| 287 | + let trust = FediverseTrustService::new(db.clone()); | |
| 288 | + if !trust.is_enabled().await? { | |
| 289 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 290 | + } | |
| 291 | + | |
| 292 | + let Some(domain) = extract_handle_domain(&form.handle) else { | |
| 293 | + return Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()); | |
| 294 | + }; | |
| 295 | + | |
| 296 | + if !trust.is_domain_allowed(&domain).await? { | |
| 297 | + return Ok(Redirect::to("/login/fediverse/error?reason=untrusted").into_response()); | |
| 298 | + } | |
| 299 | + | |
| 300 | + let req = InitiateRequest { | |
| 301 | + handle: form.handle.trim().to_string(), | |
| 302 | + redirect_url: None, | |
| 303 | + }; | |
| 304 | + match FediverseAuthService::initiate(&db, req, CHALLENGE_TTL).await { | |
| 305 | + Ok(challenge) => Ok(Redirect::to(&format!( | |
| 306 | + "/login/fediverse/waiting/{}", | |
| 307 | + challenge.id | |
| 308 | + )) | |
| 309 | + .into_response()), | |
| 310 | + Err(AppError::Validation(_)) => { | |
| 311 | + Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()) | |
| 312 | + } | |
| 313 | + Err(err) => { | |
| 314 | + tracing::error!("FediverseAuthService::initiate failed: {err}"); | |
| 315 | + Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()) | |
| 316 | + } | |
| 317 | + } | |
| 318 | +} | |
| 319 | + | |
| 320 | +// ───────────────────────────────────────────────────────────────────── | |
| 321 | +// /login/fediverse/waiting/{id} — page d'attente + SSE (iter. 3) | |
| 322 | +// ───────────────────────────────────────────────────────────────────── | |
| 323 | + | |
| 324 | +/// Askama template for `GET /login/fediverse/waiting/{challenge_id}`. | |
| 325 | +#[derive(Template)] | |
| 326 | +#[template(path = "login_waiting.html")] | |
| 327 | +#[allow(dead_code, missing_docs)] | |
| 328 | +pub struct FediverseLoginWaitingTemplate { | |
| 329 | + pub current_locale: String, | |
| 330 | + pub page_title: String, | |
| 331 | + pub subtitle: String, | |
| 332 | + pub heading: String, | |
| 333 | + pub explainer: String, | |
| 334 | + pub cancel_button: String, | |
| 335 | + /// Challenge UUID — embedded in the JS so the EventSource opens the | |
| 336 | + /// correct SSE stream URL. | |
| 337 | + pub challenge_id: String, | |
| 338 | +} | |
| 339 | + | |
| 340 | +rustwarden_core::impl_template_response!(FediverseLoginWaitingTemplate); | |
| 341 | + | |
| 342 | +/// `GET /login/fediverse/waiting/{challenge_id}` — render the waiting | |
| 343 | +/// page that opens an `EventSource` against the SSE endpoint below. | |
| 344 | +pub async fn waiting( | |
| 345 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 346 | + Extension(locale): Extension<Locale>, | |
| 347 | + State(db): State<DatabaseConnection>, | |
| 348 | + Path(challenge_id): Path<Uuid>, | |
| 349 | +) -> Result<axum::response::Response, AppError> { | |
| 350 | + let trust = FediverseTrustService::new(db.clone()); | |
| 351 | + if !trust.is_enabled().await? { | |
| 352 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 353 | + } | |
| 354 | + | |
| 355 | + // Verify the challenge exists; otherwise show the error page rather | |
| 356 | + // than a stale spinner. | |
| 357 | + if FediverseAuthService::status(&db, challenge_id).await.is_err() { | |
| 358 | + return Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()); | |
| 359 | + } | |
| 360 | + | |
| 361 | + let locale_str = locale.as_str(); | |
| 362 | + let template = FediverseLoginWaitingTemplate { | |
| 363 | + current_locale: locale_str.to_string(), | |
| 364 | + page_title: i18n_service | |
| 365 | + .translate("fediverse.auth.waiting.page_title", locale_str) | |
| 366 | + .await, | |
| 367 | + subtitle: i18n_service | |
| 368 | + .translate("fediverse.auth.waiting.subtitle", locale_str) | |
| 369 | + .await, | |
| 370 | + heading: i18n_service | |
| 371 | + .translate("fediverse.auth.waiting.heading", locale_str) | |
| 372 | + .await, | |
| 373 | + explainer: i18n_service | |
| 374 | + .translate("fediverse.auth.waiting.explainer", locale_str) | |
| 375 | + .await, | |
| 376 | + cancel_button: i18n_service | |
| 377 | + .translate("fediverse.auth.waiting.cancel", locale_str) | |
| 378 | + .await, | |
| 379 | + challenge_id: challenge_id.to_string(), | |
| 380 | + }; | |
| 381 | + | |
| 382 | + Ok(template.into_response()) | |
| 383 | +} | |
| 384 | + | |
| 385 | +/// Polling cadence between two DB reads in [`sse_status`]. Two seconds | |
| 386 | +/// is the master plan §10 default — small enough that the user | |
| 387 | +/// perceives "live", large enough to keep the DB load negligible. | |
| 388 | +const SSE_POLL_INTERVAL: Duration = Duration::from_secs(2); | |
| 389 | + | |
| 390 | +/// Maximum number of polls before the SSE stream gives up and closes. | |
| 391 | +/// `5 min / 2 s = 150` matches the challenge TTL exactly so a never- | |
| 392 | +/// answered challenge does not keep the connection alive forever. | |
| 393 | +const SSE_MAX_POLLS: u32 = 150; | |
| 394 | + | |
| 395 | +/// `GET /login/fediverse/sse/{challenge_id}` — SSE polling endpoint. | |
| 396 | +/// | |
| 397 | +/// Emits one `event: status` per poll with payload | |
| 398 | +/// `{"state":"pending|approved|rejected|expired|consumed"}`. Stops after | |
| 399 | +/// the first terminal status or [`SSE_MAX_POLLS`] iterations, whichever | |
| 400 | +/// comes first. The endpoint is **public** — the challenge UUID is | |
| 401 | +/// itself the unguessable bearer (256 bits of entropy, RFC 0006). | |
| 402 | +pub async fn sse_status( | |
| 403 | + State(db): State<DatabaseConnection>, | |
| 404 | + Path(challenge_id): Path<Uuid>, | |
| 405 | +) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { | |
| 406 | + let stream = async_status_stream(db, challenge_id); | |
| 407 | + Sse::new(stream).keep_alive(KeepAlive::default()) | |
| 408 | +} | |
| 409 | + | |
| 410 | +fn async_status_stream( | |
| 411 | + db: DatabaseConnection, | |
| 412 | + challenge_id: Uuid, | |
| 413 | +) -> impl Stream<Item = Result<Event, Infallible>> { | |
| 414 | + use futures::stream; | |
| 415 | + | |
| 416 | + stream::unfold( | |
| 417 | + StreamState { | |
| 418 | + db, | |
| 419 | + challenge_id, | |
| 420 | + polls: 0, | |
| 421 | + done: false, | |
| 422 | + }, | |
| 423 | + |mut s| async move { | |
| 424 | + if s.done || s.polls >= SSE_MAX_POLLS { | |
| 425 | + return None; | |
| 426 | + } | |
| 427 | + if s.polls > 0 { | |
| 428 | + tokio::time::sleep(SSE_POLL_INTERVAL).await; | |
| 429 | + } | |
| 430 | + s.polls += 1; | |
| 431 | + | |
| 432 | + let state_str = match FediverseAuthService::status(&s.db, s.challenge_id).await { | |
| 433 | + Ok(model) => ChallengeStatus::parse(&model.status).as_str(), | |
| 434 | + Err(_) => "expired", | |
| 435 | + }; | |
| 436 | + let event = Event::default() | |
| 437 | + .event("status") | |
| 438 | + .data(format!(r#"{{"state":"{state_str}"}}"#)); | |
| 439 | + | |
| 440 | + // Mark stream as terminal when status is non-pending so the | |
| 441 | + // next iteration short-circuits. | |
| 442 | + if state_str != "pending" { | |
| 443 | + s.done = true; | |
| 444 | + } | |
| 445 | + Some((Ok::<_, Infallible>(event), s)) | |
| 446 | + }, | |
| 447 | + ) | |
| 448 | +} | |
| 449 | + | |
| 450 | +struct StreamState { | |
| 451 | + db: DatabaseConnection, | |
| 452 | + challenge_id: Uuid, | |
| 453 | + polls: u32, | |
| 454 | + done: bool, | |
| 455 | +} | |
| 456 | + | |
| 457 | +// ───────────────────────────────────────────────────────────────────── | |
| 458 | +// /fediverse/consent/{id} — IdP-side consent screen (iter. 4) | |
| 459 | +// ───────────────────────────────────────────────────────────────────── | |
| 460 | + | |
| 461 | +/// Form payload for `POST /fediverse/consent/{id}`. The hidden field | |
| 462 | +/// `decision` is `"approve"` or `"reject"` ; anything else is treated | |
| 463 | +/// as a rejection (defensive default). | |
| 464 | +#[derive(Debug, Deserialize)] | |
| 465 | +pub struct ConsentForm { | |
| 466 | + /// CSRF token from the hidden form field. | |
| 467 | + pub csrf_token: String, | |
| 468 | + /// `"approve"` or `"reject"`. | |
| 469 | + pub decision: String, | |
| 470 | +} | |
| 471 | + | |
| 472 | +/// Askama template for `GET /fediverse/consent/{id}`. | |
| 473 | +#[derive(Template)] | |
| 474 | +#[template(path = "consent_screen.html")] | |
| 475 | +#[allow(dead_code, missing_docs)] | |
| 476 | +pub struct FediverseConsentTemplate { | |
| 477 | + pub current_locale: String, | |
| 478 | + pub page_title: String, | |
| 479 | + pub csrf_token: String, | |
| 480 | + pub heading: String, | |
| 481 | + pub explainer: String, | |
| 482 | + /// Domain of the requesting instance (`b.tld`). | |
| 483 | + pub requester_domain: String, | |
| 484 | + /// Handle the request claims to authenticate (`@alice@a.tld`). | |
| 485 | + pub handle: String, | |
| 486 | + pub field_label_requester: String, | |
| 487 | + pub field_label_handle: String, | |
| 488 | + pub approve_button: String, | |
| 489 | + pub reject_button: String, | |
| 490 | + /// `true` once the challenge has reached a terminal state — flips | |
| 491 | + /// the action area to a status banner instead of the form. | |
| 492 | + pub already_decided: bool, | |
| 493 | + pub already_decided_text: String, | |
| 494 | + /// Challenge UUID — embedded in the form action URL. | |
| 495 | + pub challenge_id: String, | |
| 496 | +} | |
| 497 | + | |
| 498 | +rustwarden_core::impl_template_response!(FediverseConsentTemplate); | |
| 499 | + | |
| 500 | +/// `GET /fediverse/consent/{challenge_id}` — render the consent screen | |
| 501 | +/// for a logged-in local user. | |
| 502 | +/// | |
| 503 | +/// The current implementation **renders** the screen but only fully | |
| 504 | +/// supports the *Reject* path. *Approve* requires the inbound auth | |
| 505 | +/// challenge to carry a `requested_remote_id`, which the inbox handler | |
| 506 | +/// will set in a follow-up to RFC 0006 (the existing inbox `auth_inbox` | |
| 507 | +/// at [`crate::fediverse::auth_routes`] documents this TODO at the | |
| 508 | +/// service-layer level). When `requested_remote_id` is absent the POST | |
| 509 | +/// handler refuses Approve with a clear i18n error. | |
| 510 | +pub async fn consent_form( | |
| 511 | + Extension(_claims): Extension<Claims>, | |
| 512 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 513 | + Extension(locale): Extension<Locale>, | |
| 514 | + State(db): State<DatabaseConnection>, | |
| 515 | + session: Session, | |
| 516 | + Path(challenge_id): Path<Uuid>, | |
| 517 | +) -> Result<axum::response::Response, AppError> { | |
| 518 | + let trust = FediverseTrustService::new(db.clone()); | |
| 519 | + if !trust.is_enabled().await? { | |
| 520 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 521 | + } | |
| 522 | + | |
| 523 | + let model = match FediverseAuthService::status(&db, challenge_id).await { | |
| 524 | + Ok(m) => m, | |
| 525 | + Err(_) => return Ok(axum::http::StatusCode::NOT_FOUND.into_response()), | |
| 526 | + }; | |
| 527 | + | |
| 528 | + let already_decided = ChallengeStatus::parse(&model.status) != ChallengeStatus::Pending; | |
| 529 | + | |
| 530 | + let locale_str = locale.as_str(); | |
| 531 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 532 | + | |
| 533 | + let already_decided_text = if already_decided { | |
| 534 | + i18n_service | |
| 535 | + .translate("fediverse.auth.consent.already_decided", locale_str) | |
| 536 | + .await | |
| 537 | + } else { | |
| 538 | + String::new() | |
| 539 | + }; | |
| 540 | + | |
| 541 | + let template = FediverseConsentTemplate { | |
| 542 | + current_locale: locale_str.to_string(), | |
| 543 | + page_title: i18n_service | |
| 544 | + .translate("fediverse.auth.consent.page_title", locale_str) | |
| 545 | + .await, | |
| 546 | + csrf_token, | |
| 547 | + heading: i18n_service | |
| 548 | + .translate("fediverse.auth.consent.heading", locale_str) | |
| 549 | + .await, | |
| 550 | + explainer: i18n_service | |
| 551 | + .translate("fediverse.auth.consent.explainer", locale_str) | |
| 552 | + .await, | |
| 553 | + requester_domain: model.domain, | |
| 554 | + handle: model.handle, | |
| 555 | + field_label_requester: i18n_service | |
| 556 | + .translate("fediverse.auth.consent.label_requester", locale_str) | |
| 557 | + .await, | |
| 558 | + field_label_handle: i18n_service | |
| 559 | + .translate("fediverse.auth.consent.label_handle", locale_str) | |
| 560 | + .await, | |
| 561 | + approve_button: i18n_service | |
| 562 | + .translate("fediverse.auth.consent.approve", locale_str) | |
| 563 | + .await, | |
| 564 | + reject_button: i18n_service | |
| 565 | + .translate("fediverse.auth.consent.reject", locale_str) | |
| 566 | + .await, | |
| 567 | + already_decided, | |
| 568 | + already_decided_text, | |
| 569 | + challenge_id: challenge_id.to_string(), | |
| 570 | + }; | |
| 571 | + | |
| 572 | + Ok(template.into_response()) | |
| 573 | +} | |
| 574 | + | |
| 575 | +/// `POST /fediverse/consent/{challenge_id}` — record the user's | |
| 576 | +/// decision and redirect to `/home`. | |
| 577 | +/// | |
| 578 | +/// *Reject* always succeeds. *Approve* requires the challenge row to | |
| 579 | +/// have a `requested_remote_id` (set by the inbox handler when the | |
| 580 | +/// `AuthRequest` arrives). Without it we surface a Validation error | |
| 581 | +/// rather than mint an unbound approval, which would let an attacker | |
| 582 | +/// inject any `remote_identity_id` from the form. The follow-up RFC | |
| 583 | +/// 0006 Phase 2 will close the binding. | |
| 584 | +pub async fn consent_submit( | |
| 585 | + Extension(claims): Extension<Claims>, | |
| 586 | + State(db): State<DatabaseConnection>, | |
| 587 | + session: Session, | |
| 588 | + Path(challenge_id): Path<Uuid>, | |
| 589 | + Form(form): Form<ConsentForm>, | |
| 590 | +) -> Result<axum::response::Response, AppError> { | |
| 591 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 592 | + | |
| 593 | + let trust = FediverseTrustService::new(db.clone()); | |
| 594 | + if !trust.is_enabled().await? { | |
| 595 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 596 | + } | |
| 597 | + | |
| 598 | + // Pre-fetch challenge: we need `nonce` + `requested_remote_id` both | |
| 599 | + // to feed `record_decision` and to enqueue the outbound back-channel | |
| 600 | + // (RFC 0025). Doing this once avoids a second roundtrip on approve. | |
| 601 | + let challenge = FediverseAuthService::status(&db, challenge_id).await?; | |
| 602 | + let remote_id = challenge.requested_remote_id.ok_or_else(|| { | |
| 603 | + AppError::Validation( | |
| 604 | + "auth challenge has no remote_identity_id binding — inbox \ | |
| 605 | + enrichment (RFC 0006 Phase 2) not yet wired" | |
| 606 | + .to_string(), | |
| 607 | + ) | |
| 608 | + })?; | |
| 609 | + | |
| 610 | + let approve_flag = form.decision.as_str() == "approve"; | |
| 611 | + let decision = if approve_flag { | |
| 612 | + ChallengeDecision::Approve { | |
| 613 | + remote_identity_id: remote_id, | |
| 614 | + } | |
| 615 | + } else { | |
| 616 | + ChallengeDecision::Reject | |
| 617 | + }; | |
| 618 | + | |
| 619 | + FediverseAuthService::record_decision(&db, challenge_id, decision).await?; | |
| 620 | + | |
| 621 | + // RFC 0025 — push the decision to B's inbox as Accept(AuthRequest) | |
| 622 | + // / Reject(AuthRequest). Failure to enqueue must not roll back the | |
| 623 | + // local decision (the row already moved past Pending), but we | |
| 624 | + // surface it so the operator sees the dead-letter / retry need. | |
| 625 | + if let Err(e) = enqueue_auth_decision(&db, &claims, &challenge, approve_flag).await { | |
| 626 | + tracing::error!( | |
| 627 | + challenge_id = %challenge_id, | |
| 628 | + decision = if approve_flag { "approve" } else { "reject" }, | |
| 629 | + error = %e, | |
| 630 | + "consent_submit: failed to enqueue back-channel activity", | |
| 631 | + ); | |
| 632 | + } | |
| 633 | + | |
| 634 | + Ok(Redirect::to("/home").into_response()) | |
| 635 | +} | |
| 636 | + | |
| 637 | +/// Build and enqueue the `Accept(AuthRequest)` or `Reject(AuthRequest)` | |
| 638 | +/// activity (RFC 0025) addressed to the requester's inbox. | |
| 639 | +/// | |
| 640 | +/// Resolves the requester via `challenge.requested_remote_id`. The | |
| 641 | +/// approver actor URL is derived from the IdP's `FediverseConfig` base | |
| 642 | +/// + the approver's username (in `Claims`). | |
| 643 | +async fn enqueue_auth_decision( | |
| 644 | + db: &DatabaseConnection, | |
| 645 | + approver: &Claims, | |
| 646 | + challenge: &fediverse_auth_challenge::Model, | |
| 647 | + approve: bool, | |
| 648 | +) -> Result<(), AppError> { | |
| 649 | + let remote_id = challenge.requested_remote_id.ok_or_else(|| { | |
| 650 | + AppError::Internal("challenge approved without requested_remote_id".into()) | |
| 651 | + })?; | |
| 652 | + | |
| 653 | + let requester = remote_identity::Entity::find_by_id(remote_id) | |
| 654 | + .one(db) | |
| 655 | + .await? | |
| 656 | + .ok_or_else(|| { | |
| 657 | + AppError::Internal(format!("remote_identity {remote_id} disappeared between checks")) | |
| 658 | + })?; | |
| 659 | + | |
| 660 | + let config = FediverseConfig::from_env() | |
| 661 | + .map_err(|e| AppError::Config(format!("FediverseConfig::from_env: {e}")))?; | |
| 662 | + let approver_actor_url = format!("{}users/{}", config.base_url(), approver.username); | |
| 663 | + | |
| 664 | + let payload = build_auth_decision_payload( | |
| 665 | + config.base_url(), | |
| 666 | + &approver_actor_url, | |
| 667 | + &requester.inbox_url, | |
| 668 | + &challenge.nonce, | |
| 669 | + approve, | |
| 670 | + ); | |
| 671 | + let activity_id = payload["id"].as_str().unwrap_or("").to_string(); | |
| 672 | + | |
| 673 | + FediverseOutboundService::enqueue( | |
| 674 | + db, | |
| 675 | + EnqueueRequest { | |
| 676 | + activity_id, | |
| 677 | + payload_json: serde_json::to_string(&payload) | |
| 678 | + .map_err(|e| AppError::Internal(format!("payload serialize: {e}")))?, | |
| 679 | + target_inbox: requester.inbox_url.clone(), | |
| 680 | + target_domain: requester.domain.clone(), | |
| 681 | + actor_user_id: Some(approver.sub), | |
| 682 | + }, | |
| 683 | + ) | |
| 684 | + .await?; | |
| 685 | + Ok(()) | |
| 686 | +} | |
| 687 | + | |
| 688 | +// ───────────────────────────────────────────────────────────────────── | |
| 689 | +// /profile/connected_apps (RFC 0024 Slice C iter. 5) | |
| 690 | +// ───────────────────────────────────────────────────────────────────── | |
| 691 | + | |
| 692 | +/// One row rendered into the connected-apps table — a remote instance | |
| 693 | +/// where the current user has an active federated session (= a | |
| 694 | +/// challenge in `approved` or `consumed` status). | |
| 695 | +#[derive(Debug, Clone)] | |
| 696 | +pub struct ConnectedAppRowCtx { | |
| 697 | + /// Domain of the requesting instance (`b.tld`). | |
| 698 | + pub domain: String, | |
| 699 | + /// Number of distinct sessions / approvals for that domain. | |
| 700 | + pub session_count: u64, | |
| 701 | + /// Most recent `created_at` for this domain, formatted | |
| 702 | + /// `YYYY-MM-DD HH:MM`. | |
| 703 | + pub last_seen: String, | |
| 704 | +} | |
| 705 | + | |
| 706 | +/// Askama template for `GET /profile/connected_apps`. | |
| 707 | +#[derive(Template)] | |
| 708 | +#[template(path = "connected_apps.html")] | |
| 709 | +#[allow(dead_code, missing_docs)] | |
| 710 | +pub struct ConnectedAppsTemplate { | |
| 711 | + pub current_locale: String, | |
| 712 | + pub page_title: String, | |
| 713 | + pub heading: String, | |
| 714 | + pub subtitle: String, | |
| 715 | + pub rows: Vec<ConnectedAppRowCtx>, | |
| 716 | + pub has_rows: bool, | |
| 717 | + pub table_domain: String, | |
| 718 | + pub table_count: String, | |
| 719 | + pub table_last_seen: String, | |
| 720 | + pub empty_title: String, | |
| 721 | + pub empty_body: String, | |
| 722 | + pub revoke_note: String, | |
| 723 | + pub back_to_profile: String, | |
| 724 | +} | |
| 725 | + | |
| 726 | +rustwarden_core::impl_template_response!(ConnectedAppsTemplate); | |
| 727 | + | |
| 728 | +/// `GET /profile/connected_apps` — section profil affichant la liste | |
| 729 | +/// des instances fédérées où l'utilisateur a actuellement une session. | |
| 730 | +/// | |
| 731 | +/// Mode dégradé silencieux (Plan UI §3 P9) : `404` quand | |
| 732 | +/// `fediverse_enabled = false`. Lecture seule en iter. 5 ; le bouton | |
| 733 | +/// « Révoquer » est différé à RFC 0006 Phase 2 (qui ajoutera la méthode | |
| 734 | +/// `FediverseAuthService::revoke_remote_session(challenge_id)` capable | |
| 735 | +/// de blacklister le JWT remote correspondant). | |
| 736 | +pub async fn connected_apps( | |
| 737 | + Extension(claims): Extension<Claims>, | |
| 738 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 739 | + Extension(locale): Extension<Locale>, | |
| 740 | + State(db): State<DatabaseConnection>, | |
| 741 | +) -> Result<axum::response::Response, AppError> { | |
| 742 | + let trust = FediverseTrustService::new(db.clone()); | |
| 743 | + if !trust.is_enabled().await? { | |
| 744 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 745 | + } | |
| 746 | + | |
| 747 | + // Build the local handle of the current user. | |
| 748 | + let domain_self = std::env::var("FEDIVERSE_DOMAIN") | |
| 749 | + .unwrap_or_else(|_| "localhost".to_string()); | |
| 750 | + let local_handle = format!("@{}@{}", claims.username, domain_self); | |
| 751 | + | |
| 752 | + // Pull all `approved` / `consumed` challenges for that handle. | |
| 753 | + let challenges = fediverse_auth_challenge::Entity::find() | |
| 754 | + .filter(fediverse_auth_challenge::Column::Handle.eq(local_handle)) | |
| 755 | + .filter( | |
| 756 | + fediverse_auth_challenge::Column::Status | |
| 757 | + .is_in(["approved", "consumed"]), | |
| 758 | + ) | |
| 759 | + .order_by_desc(fediverse_auth_challenge::Column::CreatedAt) | |
| 760 | + .all(&db) | |
| 761 | + .await?; | |
| 762 | + | |
| 763 | + // Group by domain: count + most recent created_at. | |
| 764 | + use std::collections::BTreeMap; | |
| 765 | + let mut grouped: BTreeMap<String, (u64, chrono::DateTime<chrono::Utc>)> = BTreeMap::new(); | |
| 766 | + for c in challenges { | |
| 767 | + let entry = grouped | |
| 768 | + .entry(c.domain.clone()) | |
| 769 | + .or_insert((0, c.created_at)); | |
| 770 | + entry.0 += 1; | |
| 771 | + if c.created_at > entry.1 { | |
| 772 | + entry.1 = c.created_at; | |
| 773 | + } | |
| 774 | + } | |
| 775 | + let mut rows: Vec<ConnectedAppRowCtx> = grouped | |
| 776 | + .into_iter() | |
| 777 | + .map(|(domain, (count, last))| ConnectedAppRowCtx { | |
| 778 | + domain, | |
| 779 | + session_count: count, | |
| 780 | + last_seen: last.format("%Y-%m-%d %H:%M").to_string(), | |
| 781 | + }) | |
| 782 | + .collect(); | |
| 783 | + // Most recently active first. | |
| 784 | + rows.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); | |
| 785 | + let has_rows = !rows.is_empty(); | |
| 786 | + | |
| 787 | + let locale_str = locale.as_str(); | |
| 788 | + let t = |k: &str| { | |
| 789 | + let i18n = i18n_service.clone(); | |
| 790 | + let key = k.to_string(); | |
| 791 | + let loc = locale_str.to_string(); | |
| 792 | + async move { i18n.translate(&key, &loc).await } | |
| 793 | + }; | |
| 794 | + | |
| 795 | + let template = ConnectedAppsTemplate { | |
| 796 | + current_locale: locale_str.to_string(), | |
| 797 | + page_title: t("fediverse_connected_apps.page_title").await, | |
| 798 | + heading: t("fediverse_connected_apps.heading").await, | |
| 799 | + subtitle: t("fediverse_connected_apps.subtitle").await, | |
| 800 | + rows, | |
| 801 | + has_rows, | |
| 802 | + table_domain: t("fediverse_connected_apps.table_domain").await, | |
| 803 | + table_count: t("fediverse_connected_apps.table_count").await, | |
| 804 | + table_last_seen: t("fediverse_connected_apps.table_last_seen").await, | |
| 805 | + empty_title: t("fediverse_connected_apps.empty_title").await, | |
| 806 | + empty_body: t("fediverse_connected_apps.empty_body").await, | |
| 807 | + revoke_note: t("fediverse_connected_apps.revoke_note").await, | |
| 808 | + back_to_profile: t("fediverse_connected_apps.back_to_profile").await, | |
| 809 | + }; | |
| 810 | + | |
| 811 | + Ok(template.into_response()) | |
| 812 | +} | |
| 813 | + | |
| 814 | +// ───────────────────────────────────────────────────────────────────── | |
| 815 | +// /fediverse/invite/{id} (RFC 0024 Slice C iter. 6) | |
| 816 | +// ───────────────────────────────────────────────────────────────────── | |
| 817 | + | |
| 818 | +/// Form payload for `POST /fediverse/invite/{id}`. | |
| 819 | +#[derive(Debug, Deserialize)] | |
| 820 | +pub struct InviteLandingForm { | |
| 821 | + /// CSRF token from the hidden form field. | |
| 822 | + pub csrf_token: String, | |
| 823 | + /// `"accept"` or `"reject"`. | |
| 824 | + pub decision: String, | |
| 825 | +} | |
| 826 | + | |
| 827 | +/// Askama template for `GET /fediverse/invite/{id}`. | |
| 828 | +#[derive(Template)] | |
| 829 | +#[template(path = "invite_landing.html")] | |
| 830 | +#[allow(dead_code, missing_docs)] | |
| 831 | +pub struct InviteLandingTemplate { | |
| 832 | + pub current_locale: String, | |
| 833 | + pub page_title: String, | |
| 834 | + pub csrf_token: String, | |
| 835 | + pub heading: String, | |
| 836 | + pub explainer: String, | |
| 837 | + pub team_label: String, | |
| 838 | + pub team_name: String, | |
| 839 | + pub role_label: String, | |
| 840 | + pub role_value: String, | |
| 841 | + pub status_label: String, | |
| 842 | + pub status_value: String, | |
| 843 | + pub accept_button: String, | |
| 844 | + pub reject_button: String, | |
| 845 | + /// `true` when the invite is in a terminal state (Accepted / Rejected | |
| 846 | + /// / Revoked / Expired) — flips the UI to a status banner. | |
| 847 | + pub already_decided: bool, | |
| 848 | + pub already_decided_text: String, | |
| 849 | + /// Challenge UUID for form action. | |
| 850 | + pub invite_id: String, | |
| 851 | + /// `true` when the current user can accept (= remote identity bound). | |
| 852 | + pub can_accept: bool, | |
| 853 | + pub cannot_accept_note: String, | |
| 854 | +} | |
| 855 | + | |
| 856 | +rustwarden_core::impl_template_response!(InviteLandingTemplate); | |
| 857 | + | |
| 858 | +/// `GET /fediverse/invite/{invite_id}` — landing page for a federated | |
| 859 | +/// team invitation. | |
| 860 | +/// | |
| 861 | +/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` or | |
| 862 | +/// the invite does not exist. The current user (`Claims`) must be a | |
| 863 | +/// **Remote** identity to be able to accept — local users only see | |
| 864 | +/// the page in read-only mode (the form refuses to call `accept` | |
| 865 | +/// because `record_decision` requires `remote_identity_id`). | |
| 866 | +pub async fn invite_landing_form( | |
| 867 | + Extension(claims): Extension<Claims>, | |
| 868 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 869 | + Extension(locale): Extension<Locale>, | |
| 870 | + State(db): State<DatabaseConnection>, | |
| 871 | + session: Session, | |
| 872 | + axum::extract::Path(invite_id): axum::extract::Path<Uuid>, | |
| 873 | +) -> Result<axum::response::Response, AppError> { | |
| 874 | + let trust = FediverseTrustService::new(db.clone()); | |
| 875 | + if !trust.is_enabled().await? { | |
| 876 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 877 | + } | |
| 878 | + | |
| 879 | + let Some(invite) = fediverse_pending_invite::Entity::find_by_id(invite_id) | |
| 880 | + .one(&db) | |
| 881 | + .await? | |
| 882 | + else { | |
| 883 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Invite not found").into_response()); | |
| 884 | + }; | |
| 885 | + | |
| 886 | + // Load team name for display. | |
| 887 | + let team_name = rustwarden_core::models::team::Entity::find_by_id(invite.team_id) | |
| 888 | + .one(&db) | |
| 889 | + .await? | |
| 890 | + .map(|t| t.name) | |
| 891 | + .unwrap_or_else(|| invite.team_id.to_string()); | |
| 892 | + | |
| 893 | + // Status terminal? | |
| 894 | + use rustwarden_fediverse::services::fediverse_invite_service::InviteStatus; | |
| 895 | + let status_parsed = InviteStatus::parse(&invite.status); | |
| 896 | + let already_decided = status_parsed != InviteStatus::Pending; | |
| 897 | + | |
| 898 | + // Can accept = current user has a remote_identity_id (RFC 0007). | |
| 899 | + let can_accept = claims.is_remote() && claims.remote_identity_id().is_some(); | |
| 900 | + | |
| 901 | + let locale_str = locale.as_str(); | |
| 902 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 903 | + | |
| 904 | + let t = |k: &str| { | |
| 905 | + let i18n = i18n_service.clone(); | |
| 906 | + let key = k.to_string(); | |
| 907 | + let loc = locale_str.to_string(); | |
| 908 | + async move { i18n.translate(&key, &loc).await } | |
| 909 | + }; | |
| 910 | + | |
| 911 | + let template = InviteLandingTemplate { | |
| 912 | + current_locale: locale_str.to_string(), | |
| 913 | + page_title: t("fediverse_invite_landing.page_title").await, | |
| 914 | + csrf_token, | |
| 915 | + heading: t("fediverse_invite_landing.heading").await, | |
| 916 | + explainer: t("fediverse_invite_landing.explainer").await, | |
| 917 | + team_label: t("fediverse_invite_landing.team_label").await, | |
| 918 | + team_name, | |
| 919 | + role_label: t("fediverse_invite_landing.role_label").await, | |
| 920 | + role_value: invite.role.clone(), | |
| 921 | + status_label: t("fediverse_invite_landing.status_label").await, | |
| 922 | + status_value: invite.status.clone(), | |
| 923 | + accept_button: t("fediverse_invite_landing.accept_button").await, | |
| 924 | + reject_button: t("fediverse_invite_landing.reject_button").await, | |
| 925 | + already_decided, | |
| 926 | + already_decided_text: if already_decided { | |
| 927 | + t("fediverse_invite_landing.already_decided").await | |
| 928 | + } else { | |
| 929 | + String::new() | |
| 930 | + }, | |
| 931 | + invite_id: invite_id.to_string(), | |
| 932 | + can_accept, | |
| 933 | + cannot_accept_note: if can_accept { | |
| 934 | + String::new() | |
| 935 | + } else { | |
| 936 | + t("fediverse_invite_landing.cannot_accept_note").await | |
| 937 | + }, | |
| 938 | + }; | |
| 939 | + | |
| 940 | + Ok(template.into_response()) | |
| 941 | +} | |
| 942 | + | |
| 943 | +/// `POST /fediverse/invite/{invite_id}` — record the invitee's decision. | |
| 944 | +/// | |
| 945 | +/// *Reject* always succeeds. *Accept* requires the current user to be a | |
| 946 | +/// **Remote** identity carrying a `remote_identity_id` claim — the | |
| 947 | +/// service inserts the `team_members` row with that FK. | |
| 948 | +pub async fn invite_landing_submit( | |
| 949 | + Extension(claims): Extension<Claims>, | |
| 950 | + State(db): State<DatabaseConnection>, | |
| 951 | + session: Session, | |
| 952 | + axum::extract::Path(invite_id): axum::extract::Path<Uuid>, | |
| 953 | + Form(form): Form<InviteLandingForm>, | |
| 954 | +) -> Result<axum::response::Response, AppError> { | |
| 955 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 956 | + | |
| 957 | + let trust = FediverseTrustService::new(db.clone()); | |
| 958 | + if !trust.is_enabled().await? { | |
| 959 | + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 960 | + } | |
| 961 | + | |
| 962 | + match form.decision.as_str() { | |
| 963 | + "accept" => { | |
| 964 | + let remote_id = claims.remote_identity_id().ok_or_else(|| { | |
| 965 | + AppError::Validation( | |
| 966 | + "Only remote identities can accept federated invites; \ | |
| 967 | + local users must reject or ignore." | |
| 968 | + .into(), | |
| 969 | + ) | |
| 970 | + })?; | |
| 971 | + FediverseInviteService::accept(&db, invite_id, remote_id).await?; | |
| 972 | + } | |
| 973 | + // Anything other than "accept" is a reject (defensive default). | |
| 974 | + _ => { | |
| 975 | + FediverseInviteService::reject(&db, invite_id).await?; | |
| 976 | + } | |
| 977 | + } | |
| 978 | + | |
| 979 | + Ok(Redirect::to("/home").into_response()) | |
| 980 | +} | |
| 981 | + | |
| 982 | +#[cfg(test)] | |
| 983 | +mod tests { | |
| 984 | + use super::*; | |
| 985 | + | |
| 986 | + #[test] | |
| 987 | + fn error_reason_parses_known_values() { | |
| 988 | + assert_eq!(ErrorReason::parse("timeout"), ErrorReason::Timeout); | |
| 989 | + assert_eq!(ErrorReason::parse("rejected"), ErrorReason::Rejected); | |
| 990 | + assert_eq!(ErrorReason::parse("untrusted"), ErrorReason::Untrusted); | |
| 991 | + assert_eq!( | |
| 992 | + ErrorReason::parse("invalid_signature"), | |
| 993 | + ErrorReason::InvalidSignature | |
| 994 | + ); | |
| 995 | + assert_eq!( | |
| 996 | + ErrorReason::parse("invalid_sig"), | |
| 997 | + ErrorReason::InvalidSignature | |
| 998 | + ); | |
| 999 | + } | |
| 1000 | + | |
| 1001 | + #[test] | |
| 1002 | + fn error_reason_falls_back_to_unknown() { | |
| 1003 | + assert_eq!(ErrorReason::parse(""), ErrorReason::Unknown); | |
| 1004 | + assert_eq!(ErrorReason::parse("garbage"), ErrorReason::Unknown); | |
| 1005 | + assert_eq!(ErrorReason::parse("500"), ErrorReason::Unknown); | |
| 1006 | + } | |
| 1007 | + | |
| 1008 | + #[test] | |
| 1009 | + fn error_reason_trims_and_lowercases() { | |
| 1010 | + assert_eq!(ErrorReason::parse(" TIMEOUT "), ErrorReason::Timeout); | |
| 1011 | + assert_eq!( | |
| 1012 | + ErrorReason::parse("Invalid_Signature"), | |
| 1013 | + ErrorReason::InvalidSignature | |
| 1014 | + ); | |
| 1015 | + } | |
| 1016 | + | |
| 1017 | + #[test] | |
| 1018 | + fn extract_handle_domain_accepts_canonical() { | |
| 1019 | + assert_eq!( | |
| 1020 | + extract_handle_domain("@alice@example.org"), | |
| 1021 | + Some("example.org".to_string()) | |
| 1022 | + ); | |
| 1023 | + assert_eq!( | |
| 1024 | + extract_handle_domain("alice@example.org"), | |
| 1025 | + Some("example.org".to_string()) | |
| 1026 | + ); | |
| 1027 | + assert_eq!( | |
| 1028 | + extract_handle_domain(" @bob@MASTODON.SOCIAL "), | |
| 1029 | + Some("mastodon.social".to_string()) | |
| 1030 | + ); | |
| 1031 | + } | |
| 1032 | + | |
| 1033 | + #[test] | |
| 1034 | + fn extract_handle_domain_rejects_malformed() { | |
| 1035 | + assert_eq!(extract_handle_domain(""), None); | |
| 1036 | + assert_eq!(extract_handle_domain("alice"), None); | |
| 1037 | + assert_eq!(extract_handle_domain("alice@"), None); | |
| 1038 | + assert_eq!(extract_handle_domain("@@example.org"), None); | |
| 1039 | + } | |
| 1040 | + | |
| 1041 | + #[test] | |
| 1042 | + fn error_reason_round_trips_through_str() { | |
| 1043 | + for r in [ | |
| 1044 | + ErrorReason::Timeout, | |
| 1045 | + ErrorReason::Rejected, | |
| 1046 | + ErrorReason::Untrusted, | |
| 1047 | + ErrorReason::InvalidSignature, | |
| 1048 | + ErrorReason::Unknown, | |
| 1049 | + ] { | |
| 1050 | + assert_eq!(ErrorReason::parse(r.as_str()), r); | |
| 1051 | + } | |
| 1052 | + } | |
| 1053 | +} |
A
crates/rustwarden-fediverse-ui/src/lib.rs
+23
-0
@@ -0,0 +1,23 @@
| 1 | +//! Reference HTML UI for Rustwarden ActivityPub federation. | |
| 2 | +//! | |
| 3 | +//! Ships handlers, Askama templates, default routes and a dashboard card | |
| 4 | +//! DTO for the federation flows (RFC 0021, 0023, 0024). Consumers that | |
| 5 | +//! provide their own UI can link only [`rustwarden_fediverse`] (headless | |
| 6 | +//! protocol) and ignore this crate; otherwise mount [`default_routes`] | |
| 7 | +//! and inject [`dashboard_card`] into the admin dashboard. | |
| 8 | + | |
| 9 | +/// Federation dashboard card DTO factory. | |
| 10 | +pub mod dashboard; | |
| 11 | +/// Shared template DTOs (admin tabs, etc.). | |
| 12 | +pub mod dto; | |
| 13 | +/// HTTP request handlers (admin pages, login flow, consent, invites). | |
| 14 | +pub mod handlers; | |
| 15 | +/// Default Axum router for all federation UI routes. | |
| 16 | +pub mod routes; | |
| 17 | +/// Reference template strings exposed via `include_str!` for projects | |
| 18 | +/// that re-implement their own rendering layer. | |
| 19 | +pub mod template_examples; | |
| 20 | + | |
| 21 | +pub use dashboard::dashboard_card; | |
| 22 | +pub use dto::{admin_tabs, AdminTabDTO}; | |
| 23 | +pub use routes::default_routes; |
A
crates/rustwarden-fediverse-ui/src/routes.rs
+88
-0
@@ -0,0 +1,88 @@
| 1 | +//! Default federation UI router. | |
| 2 | +//! | |
| 3 | +//! Mounts every public, authenticated and admin endpoint shipped by | |
| 4 | +//! this crate. Mirrors the previous `cfg(feature = "fediverse")` blocks | |
| 5 | +//! that lived in `rustwarden-ui` so wiring stays a one-liner: | |
| 6 | +//! `builder.merge_routes(rustwarden_fediverse_ui::default_routes())`. | |
| 7 | + | |
| 8 | +use axum::routing::{get, post}; | |
| 9 | +use axum::Router; | |
| 10 | +use sea_orm::DatabaseConnection; | |
| 11 | + | |
| 12 | +use crate::handlers::{admin, user_auth}; | |
| 13 | + | |
| 14 | +/// Returns the default federation UI routes (public, authenticated and | |
| 15 | +/// admin) wired against `DatabaseConnection` state. | |
| 16 | +pub fn default_routes() -> Router<DatabaseConnection> { | |
| 17 | + Router::new() | |
| 18 | + // Federation admin hub (entry point for the six sub-sections). | |
| 19 | + .route("/admin/fediverse", get(admin::get_hub)) | |
| 20 | + .route( | |
| 21 | + "/admin/fediverse/teams", | |
| 22 | + get(admin::get_teams_for_invites), | |
| 23 | + ) | |
| 24 | + // Public routes (RFC 0023 Slice B). | |
| 25 | + .route("/login/fediverse/error", get(user_auth::error)) | |
| 26 | + .route( | |
| 27 | + "/login/fediverse", | |
| 28 | + get(user_auth::login_form).post(user_auth::login_initiate), | |
| 29 | + ) | |
| 30 | + .route( | |
| 31 | + "/login/fediverse/waiting/{challenge_id}", | |
| 32 | + get(user_auth::waiting), | |
| 33 | + ) | |
| 34 | + .route( | |
| 35 | + "/login/fediverse/sse/{challenge_id}", | |
| 36 | + get(user_auth::sse_status), | |
| 37 | + ) | |
| 38 | + // Authenticated routes (consent, invite, connected apps). | |
| 39 | + .route( | |
| 40 | + "/fediverse/consent/{challenge_id}", | |
| 41 | + get(user_auth::consent_form).post(user_auth::consent_submit), | |
| 42 | + ) | |
| 43 | + .route("/profile/connected_apps", get(user_auth::connected_apps)) | |
| 44 | + .route( | |
| 45 | + "/fediverse/invite/{invite_id}", | |
| 46 | + get(user_auth::invite_landing_form).post(user_auth::invite_landing_submit), | |
| 47 | + ) | |
| 48 | + // Admin routes (RFC 0021 Slice A). | |
| 49 | + .route( | |
| 50 | + "/admin/fediverse/settings", | |
| 51 | + get(admin::get_settings).post(admin::update_settings), | |
| 52 | + ) | |
| 53 | + .route("/admin/fediverse/trust_list", get(admin::get_trust_list)) | |
| 54 | + .route( | |
| 55 | + "/admin/fediverse/trust_list/add", | |
| 56 | + post(admin::add_trust_entry), | |
| 57 | + ) | |
| 58 | + .route( | |
| 59 | + "/admin/fediverse/trust_list/delete", | |
| 60 | + post(admin::delete_trust_entry), | |
| 61 | + ) | |
| 62 | + .route("/admin/fediverse/queue", get(admin::get_queue)) | |
| 63 | + .route( | |
| 64 | + "/admin/fediverse/sessions_remote", | |
| 65 | + get(admin::get_sessions_remote), | |
| 66 | + ) | |
| 67 | + .route( | |
| 68 | + "/admin/fediverse/sessions_remote/revoke", | |
| 69 | + post(admin::revoke_remote_session), | |
| 70 | + ) | |
| 71 | + .route( | |
| 72 | + "/admin/fediverse/sessions_remote/kill_domain", | |
| 73 | + post(admin::kill_domain), | |
| 74 | + ) | |
| 75 | + .route("/admin/fediverse/nodeinfo", get(admin::get_nodeinfo)) | |
| 76 | + .route( | |
| 77 | + "/admin/fediverse/invites/{team_id}", | |
| 78 | + get(admin::get_team_invites), | |
| 79 | + ) | |
| 80 | + .route( | |
| 81 | + "/admin/fediverse/invites/{team_id}/add", | |
| 82 | + post(admin::create_team_invite), | |
| 83 | + ) | |
| 84 | + .route( | |
| 85 | + "/admin/fediverse/invites/{team_id}/revoke", | |
| 86 | + post(admin::revoke_team_invite), | |
| 87 | + ) | |
| 88 | +} |
A
crates/rustwarden-fediverse-ui/src/template_examples.rs
+29
-0
@@ -0,0 +1,29 @@
| 1 | +//! Reference template strings exposed for projects re-implementing their own UI. | |
| 2 | +//! | |
| 3 | +//! Each constant contains the exact Askama source of a federation HTML template, | |
| 4 | +//! suitable for adaptation to other rendering engines (Tera, Maud, Leptos…). | |
| 5 | + | |
| 6 | +/// Consent screen shown after a successful federated login. | |
| 7 | +pub const CONSENT_SCREEN: &str = include_str!("../templates/consent_screen.html"); | |
| 8 | +/// Error landing page for `/login/fediverse/error`. | |
| 9 | +pub const ERROR_UNAUTHORIZED: &str = include_str!("../templates/error_unauthorized.html"); | |
| 10 | +/// Landing page for one-shot federation invites. | |
| 11 | +pub const INVITE_LANDING: &str = include_str!("../templates/invite_landing.html"); | |
| 12 | +/// Federation login form (`@user@domain` input). | |
| 13 | +pub const LOGIN_FORM: &str = include_str!("../templates/login_form.html"); | |
| 14 | +/// Waiting page rendered while the IdP processes the auth request. | |
| 15 | +pub const LOGIN_WAITING: &str = include_str!("../templates/login_waiting.html"); | |
| 16 | +/// Admin: NodeInfo summary page. | |
| 17 | +pub const ADMIN_NODEINFO: &str = include_str!("../templates/admin/nodeinfo.html"); | |
| 18 | +/// Admin: outbound delivery queue dashboard. | |
| 19 | +pub const ADMIN_QUEUE: &str = include_str!("../templates/admin/queue.html"); | |
| 20 | +/// Admin: remote sessions browser (and revocation actions). | |
| 21 | +pub const ADMIN_SESSIONS_REMOTE: &str = include_str!("../templates/admin/sessions_remote.html"); | |
| 22 | +/// Admin: federation runtime settings form. | |
| 23 | +pub const ADMIN_SETTINGS: &str = include_str!("../templates/admin/settings.html"); | |
| 24 | +/// Admin: per-team invite management page. | |
| 25 | +pub const ADMIN_TEAM_INVITES: &str = include_str!("../templates/admin/team_invites.html"); | |
| 26 | +/// Admin: trust-list management page. | |
| 27 | +pub const ADMIN_TRUST_LIST: &str = include_str!("../templates/admin/trust_list.html"); | |
| 28 | +/// User profile: list of connected federated apps and remote sessions. | |
| 29 | +pub const CONNECTED_APPS: &str = include_str!("../templates/connected_apps.html"); |
A
crates/rustwarden-fediverse-ui/templates/admin/hub.html
+42
-0
@@ -0,0 +1,42 @@
| 1 | +{% extends "base.html" %} | |
| 2 | + | |
| 3 | +{% block title %}{{ page_title }}{% endblock %} | |
| 4 | + | |
| 5 | +{% block navbar %} | |
| 6 | +{% include "components/navbar.html" %} | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<div class="container mx-auto px-4 py-8"> | |
| 11 | + | |
| 12 | + {# Breadcrumb (no parent for the hub itself) #} | |
| 13 | + <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 14 | + <ul> | |
| 15 | + <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 16 | + <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 17 | + <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 18 | + </ul> | |
| 19 | + </nav> | |
| 20 | + | |
| 21 | + {# Page header — no tab bar on the hub itself #} | |
| 22 | + <header class="mb-8"> | |
| 23 | + <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 24 | + <p class="text-base-content/70">{{ subtitle }}</p> | |
| 25 | + </header> | |
| 26 | + | |
| 27 | + {# Hub cards: 6 entry points, one per admin section #} | |
| 28 | + <section class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" aria-label="federation sections"> | |
| 29 | + {% for card in cards %} | |
| 30 | + <a href="{{ card.href }}" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow focus:outline-none focus:ring-2 focus:ring-primary"> | |
| 31 | + <div class="card-body"> | |
| 32 | + <h2 class="card-title"> | |
| 33 | + <span aria-hidden="true">{{ card.icon }}</span> | |
| 34 | + {{ card.title }} | |
| 35 | + </h2> | |
| 36 | + <p class="text-base-content/70">{{ card.desc }}</p> | |
| 37 | + </div> | |
| 38 | + </a> | |
| 39 | + {% endfor %} | |
| 40 | + </section> | |
| 41 | +</div> | |
| 42 | +{% endblock %} |
A
crates/rustwarden-fediverse-ui/templates/admin/nodeinfo.html
+121
-0
@@ -0,0 +1,121 @@
| 1 | +{% extends "base.html" %} | |
| 2 | + | |
| 3 | +{% block title %}{{ page_title }}{% endblock %} | |
| 4 | + | |
| 5 | +{% block navbar %} | |
| 6 | +{% include "components/navbar.html" %} | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<div class="container mx-auto px-4 py-8"> | |
| 11 | + | |
| 12 | + <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 13 | + <ul> | |
| 14 | + <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 15 | + <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 16 | + <li><a href="/admin/fediverse">{{ breadcrumb_federation }}</a></li> | |
| 17 | + <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 18 | + </ul> | |
| 19 | + </nav> | |
| 20 | + | |
| 21 | + {% include "components/admin_tabs.html" %} | |
| 22 | + | |
| 23 | + <header class="mb-8"> | |
| 24 | + <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 25 | + <p class="text-base-content/70">{{ subtitle }}</p> | |
| 26 | + </header> | |
| 27 | + | |
| 28 | + <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| 29 | + | |
| 30 | + {# Software card #} | |
| 31 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-sw"> | |
| 32 | + <div class="card-body"> | |
| 33 | + <h2 id="card-sw" class="card-title">{{ card_software }}</h2> | |
| 34 | + <dl class="space-y-2 mt-2"> | |
| 35 | + <div class="flex justify-between"> | |
| 36 | + <dt class="text-base-content/60">{{ label_name }}</dt> | |
| 37 | + <dd class="font-mono">{{ software_name }}</dd> | |
| 38 | + </div> | |
| 39 | + <div class="flex justify-between"> | |
| 40 | + <dt class="text-base-content/60">{{ label_version }}</dt> | |
| 41 | + <dd class="font-mono">{{ software_version }}</dd> | |
| 42 | + </div> | |
| 43 | + <div class="flex justify-between"> | |
| 44 | + <dt class="text-base-content/60">{{ label_domain }}</dt> | |
| 45 | + <dd class="font-mono">{{ fediverse_domain }}</dd> | |
| 46 | + </div> | |
| 47 | + </dl> | |
| 48 | + </div> | |
| 49 | + </section> | |
| 50 | + | |
| 51 | + {# Runtime config card #} | |
| 52 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-rt"> | |
| 53 | + <div class="card-body"> | |
| 54 | + <h2 id="card-rt" class="card-title">{{ card_runtime }}</h2> | |
| 55 | + <dl class="space-y-2 mt-2"> | |
| 56 | + <div class="flex justify-between"> | |
| 57 | + <dt class="text-base-content/60">{{ label_enabled }}</dt> | |
| 58 | + <dd><span class="badge badge-success">{{ yes_label }}</span></dd> | |
| 59 | + </div> | |
| 60 | + <div class="flex justify-between"> | |
| 61 | + <dt class="text-base-content/60">{{ label_trust_mode }}</dt> | |
| 62 | + <dd class="font-mono">{{ trust_mode }}</dd> | |
| 63 | + </div> | |
| 64 | + <div class="flex justify-between"> | |
| 65 | + <dt class="text-base-content/60">{{ label_interop_scope }}</dt> | |
| 66 | + <dd class="font-mono">{{ interop_scope }}</dd> | |
| 67 | + </div> | |
| 68 | + <div class="flex justify-between"> | |
| 69 | + <dt class="text-base-content/60">{{ label_authorized_fetch }}</dt> | |
| 70 | + <dd> | |
| 71 | + {% if authorized_fetch %} | |
| 72 | + <span class="badge badge-success">{{ yes_label }}</span> | |
| 73 | + {% else %} | |
| 74 | + <span class="badge">{{ no_label }}</span> | |
| 75 | + {% endif %} | |
| 76 | + </dd> | |
| 77 | + </div> | |
| 78 | + </dl> | |
| 79 | + </div> | |
| 80 | + </section> | |
| 81 | + | |
| 82 | + {# Local users card #} | |
| 83 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-us"> | |
| 84 | + <div class="card-body"> | |
| 85 | + <h2 id="card-us" class="card-title">{{ card_users }}</h2> | |
| 86 | + <div class="grid grid-cols-3 gap-2 mt-2"> | |
| 87 | + <div class="stat p-2"> | |
| 88 | + <div class="stat-title text-xs">{{ label_users_total }}</div> | |
| 89 | + <div class="stat-value text-2xl">{{ users_total }}</div> | |
| 90 | + </div> | |
| 91 | + <div class="stat p-2"> | |
| 92 | + <div class="stat-title text-xs">{{ label_users_active_month }}</div> | |
| 93 | + <div class="stat-value text-2xl text-success">{{ users_active_month }}</div> | |
| 94 | + </div> | |
| 95 | + <div class="stat p-2"> | |
| 96 | + <div class="stat-title text-xs">{{ label_users_active_halfyear }}</div> | |
| 97 | + <div class="stat-value text-2xl">{{ users_active_halfyear }}</div> | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + </section> | |
| 102 | + | |
| 103 | + {# Remote identities card #} | |
| 104 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-rm"> | |
| 105 | + <div class="card-body"> | |
| 106 | + <h2 id="card-rm" class="card-title">{{ card_remotes }}</h2> | |
| 107 | + <div class="grid grid-cols-2 gap-2 mt-2"> | |
| 108 | + <div class="stat p-2"> | |
| 109 | + <div class="stat-title text-xs">{{ label_remote_identities_active }}</div> | |
| 110 | + <div class="stat-value text-2xl">{{ remote_identities_active }}</div> | |
| 111 | + </div> | |
| 112 | + <div class="stat p-2"> | |
| 113 | + <div class="stat-title text-xs">{{ label_remote_distinct_domains }}</div> | |
| 114 | + <div class="stat-value text-2xl">{{ remote_distinct_domains }}</div> | |
| 115 | + </div> | |
| 116 | + </div> | |
| 117 | + </div> | |
| 118 | + </section> | |
| 119 | + </div> | |
| 120 | +</div> | |
| 121 | +{% endblock %} |
A
crates/rustwarden-fediverse-ui/templates/admin/queue.html
+103
-0
@@ -0,0 +1,103 @@
| 1 | +{% extends "base.html" %} | |
| 2 | + | |
| 3 | +{% block title %}{{ page_title }}{% endblock %} | |
| 4 | + | |
| 5 | +{% block navbar %} | |
| 6 | +{% include "components/navbar.html" %} | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<div class="container mx-auto px-4 py-8"> | |
| 11 | + | |
| 12 | + <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 13 | + <ul> | |
| 14 | + <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 15 | + <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 16 | + <li><a href="/admin/fediverse">{{ breadcrumb_federation }}</a></li> | |
| 17 | + <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 18 | + </ul> | |
| 19 | + </nav> | |
| 20 | + | |
| 21 | + {% include "components/admin_tabs.html" %} | |
| 22 | + | |
| 23 | + <header class="mb-8"> | |
| 24 | + <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 25 | + <p class="text-base-content/70">{{ subtitle }}</p> | |
| 26 | + </header> | |
| 27 | + | |
| 28 | + {# Stats grid (Plan UI §3 P4 — empty states inclusive) #} | |
| 29 | + <section class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8" aria-label="stats"> | |
| 30 | + <div class="stat bg-base-100 shadow rounded-lg"> | |
| 31 | + <div class="stat-title">{{ stats_pending }}</div> | |
| 32 | + <div class="stat-value text-warning">{{ count_pending }}</div> | |
| 33 | + </div> | |
| 34 | + <div class="stat bg-base-100 shadow rounded-lg"> | |
| 35 | + <div class="stat-title">{{ stats_delivered }}</div> | |
| 36 | + <div class="stat-value text-success">{{ count_delivered }}</div> | |
| 37 | + </div> | |
| 38 | + <div class="stat bg-base-100 shadow rounded-lg"> | |
| 39 | + <div class="stat-title">{{ stats_failed }}</div> | |
| 40 | + <div class="stat-value text-error">{{ count_failed }}</div> | |
| 41 | + </div> | |
| 42 | + <div class="stat bg-base-100 shadow rounded-lg"> | |
| 43 | + <div class="stat-title">{{ stats_dead }}</div> | |
| 44 | + <div class="stat-value">{{ count_dead }}</div> | |
| 45 | + </div> | |
| 46 | + </section> | |
| 47 | + | |
| 48 | + {% if has_rows %} | |
| 49 | + <section class="card bg-base-100 shadow-xl"> | |
| 50 | + <div class="card-body"> | |
| 51 | + <div class="overflow-x-auto"> | |
| 52 | + <table class="table table-zebra table-sm"> | |
| 53 | + <thead> | |
| 54 | + <tr> | |
| 55 | + <th>{{ table_activity }}</th> | |
| 56 | + <th>{{ table_target }}</th> | |
| 57 | + <th>{{ table_status }}</th> | |
| 58 | + <th>{{ table_attempts }}</th> | |
| 59 | + <th>{{ table_last_error }}</th> | |
| 60 | + <th>{{ table_next_retry }}</th> | |
| 61 | + <th>{{ table_created_at }}</th> | |
| 62 | + </tr> | |
| 63 | + </thead> | |
| 64 | + <tbody> | |
| 65 | + {% for row in rows %} | |
| 66 | + <tr> | |
| 67 | + <td class="font-mono text-xs">{{ row.activity_id }}</td> | |
| 68 | + <td class="font-mono text-xs">{{ row.target_inbox }}</td> | |
| 69 | + <td> | |
| 70 | + {% if row.status == "pending" %} | |
| 71 | + <span class="badge badge-warning">{{ row.status }}</span> | |
| 72 | + {% else if row.status == "delivered" %} | |
| 73 | + <span class="badge badge-success">{{ row.status }}</span> | |
| 74 | + {% else if row.status == "failed" %} | |
| 75 | + <span class="badge badge-error">{{ row.status }}</span> | |
| 76 | + {% else %} | |
| 77 | + <span class="badge">{{ row.status }}</span> | |
| 78 | + {% endif %} | |
| 79 | + </td> | |
| 80 | + <td>{{ row.attempts }}/{{ row.max_attempts }}</td> | |
| 81 | + <td class="text-xs max-w-xs truncate"> | |
| 82 | + {% if row.has_last_error %}{{ row.last_error }}{% else %}—{% endif %} | |
| 83 | + </td> | |
| 84 | + <td class="text-xs text-base-content/70">{{ row.next_retry_at }}</td> | |
| 85 | + <td class="text-xs text-base-content/70">{{ row.created_at }}</td> | |
| 86 | + </tr> | |
| 87 | + {% endfor %} | |
| 88 | + </tbody> | |
| 89 | + </table> | |
| 90 | + </div> | |
| 91 | + </div> | |
| 92 | + </section> | |
| 93 | + {% else %} | |
| 94 | + <section class="card bg-base-200 shadow-inner" aria-label="empty-state"> | |
| 95 | + <div class="card-body items-center text-center py-16"> | |
| 96 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> | |
| 97 | + <h3 class="text-lg font-semibold">{{ empty_title }}</h3> | |
| 98 | + <p class="text-base-content/70 max-w-md">{{ empty_body }}</p> | |
| 99 | + </div> | |
| 100 | + </section> | |
| 101 | + {% endif %} | |
| 102 | +</div> | |
| 103 | +{% endblock %} |
A
crates/rustwarden-fediverse-ui/templates/admin/sessions_remote.html
+165
-0
@@ -0,0 +1,165 @@
| 1 | +{% extends "base.html" %} | |
| 2 | + | |
| 3 | +{% block title %}{{ page_title }}{% endblock %} | |
| 4 | + | |
| 5 | +{% block navbar %} | |
| 6 | +{% include "components/navbar.html" %} | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %}, revokeTarget: null, killDomain: null }"> | |
| 11 | + | |
| 12 | + <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 13 | + <ul> | |
| 14 | + <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 15 | + <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 16 | + <li><a href="/admin/fediverse">{{ breadcrumb_federation }}</a></li> | |
| 17 | + <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 18 | + </ul> | |
| 19 | + </nav> | |
| 20 | + | |
| 21 | + {% include "components/admin_tabs.html" %} | |
| 22 | + | |
| 23 | + <header class="mb-8"> | |
| 24 | + <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 25 | + <p class="text-base-content/70">{{ subtitle }}</p> | |
| 26 | + </header> | |
| 27 | + | |
| 28 | + {# Toast on save #} | |
| 29 | + <div | |
| 30 | + x-show="saved" | |
| 31 | + x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })" | |
| 32 | + x-transition | |
| 33 | + role="status" | |
| 34 | + aria-live="polite" | |
| 35 | + class="alert alert-success mb-6" | |
| 36 | + > | |
| 37 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 38 | + <span>{{ saved_toast }}</span> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + {# Kill switch by domain (Plan UI §3 P1: one primary action per card) #} | |
| 42 | + {% if has_rows %} | |
| 43 | + <section class="card bg-base-100 shadow-xl mb-8 border-l-4 border-error" aria-labelledby="kill-legend"> | |
| 44 | + <div class="card-body"> | |
| 45 | + <h2 id="kill-legend" class="card-title text-xl mb-4 text-error">{{ kill_switch_legend }}</h2> | |
| 46 | + <div class="form-control"> | |
| 47 | + <label class="label" for="kill-domain"> | |
| 48 | + <span class="label-text font-semibold">{{ kill_switch_label }}</span> | |
| 49 | + </label> | |
| 50 | + <div class="flex gap-2 max-w-md"> | |
| 51 | + <select id="kill-domain" x-model="killDomain" class="select select-bordered flex-1"> | |
| 52 | + <option value=""></option> | |
| 53 | + {% for d in distinct_domains %} | |
| 54 | + <option value="{{ d }}">{{ d }}</option> | |
| 55 | + {% endfor %} | |
| 56 | + </select> | |
| 57 | + <button | |
| 58 | + type="button" | |
| 59 | + class="btn btn-error" | |
| 60 | + :disabled="!killDomain" | |
| 61 | + @click="if (killDomain) { document.getElementById('kill-domain-modal').showModal() }" | |
| 62 | + > | |
| 63 | + {{ kill_switch_button }} | |
| 64 | + </button> | |
| 65 | + </div> | |
| 66 | + </div> | |
| 67 | + </div> | |
| 68 | + </section> | |
| 69 | + {% endif %} | |
| 70 | + | |
| 71 | + {# Sessions table #} | |
| 72 | + {% if has_rows %} | |
| 73 | + <section class="card bg-base-100 shadow-xl"> | |
| 74 | + <div class="card-body"> | |
| 75 | + <div class="overflow-x-auto"> | |
| 76 | + <table class="table table-zebra"> | |
| 77 | + <thead> | |
| 78 | + <tr> | |
| 79 | + <th>{{ table_handle }}</th> | |
| 80 | + <th>{{ table_display_name }}</th> | |
| 81 | + <th>{{ table_domain }}</th> | |
| 82 | + <th>{{ table_last_seen }}</th> | |
| 83 | + <th class="text-right">{{ table_actions }}</th> | |
| 84 | + </tr> | |
| 85 | + </thead> | |
| 86 | + <tbody> | |
| 87 | + {% for row in rows %} | |
| 88 | + <tr> | |
| 89 | + <td class="font-mono">{{ row.handle }}</td> | |
| 90 | + <td>{% if row.has_display_name %}{{ row.display_name }}{% else %}—{% endif %}</td> | |
| 91 | + <td><span class="badge badge-ghost">{{ row.domain }}</span></td> | |
| 92 | + <td class="text-base-content/70">{{ row.last_seen }}</td> | |
| 93 | + <td class="text-right"> | |
| 94 | + <button | |
| 95 | + type="button" | |
| 96 | + class="btn btn-sm btn-ghost text-error" | |
| 97 | + @click="revokeTarget = '{{ row.id }}|{{ row.handle }}'" | |
| 98 | + aria-haspopup="dialog" | |
| 99 | + > | |
| 100 | + {{ revoke_button }} | |
| 101 | + </button> | |
| 102 | + </td> | |
| 103 | + </tr> | |
| 104 | + {% endfor %} | |
| 105 | + </tbody> | |
| 106 | + </table> | |
| 107 | + </div> | |
| 108 | + </div> | |
| 109 | + </section> | |
| 110 | + {% else %} | |
| 111 | + <section class="card bg-base-200 shadow-inner" aria-label="empty-state"> | |
| 112 | + <div class="card-body items-center text-center py-16"> | |
| 113 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg> | |
| 114 | + <h3 class="text-lg font-semibold">{{ empty_title }}</h3> | |
| 115 | + <p class="text-base-content/70 max-w-md">{{ empty_body }}</p> | |
| 116 | + </div> | |
| 117 | + </section> | |
| 118 | + {% endif %} | |
| 119 | + | |
| 120 | + {# Revoke individual session modal (Plan UI §3 P2) #} | |
| 121 | + <dialog | |
| 122 | + class="modal" | |
| 123 | + :class="{ 'modal-open': revokeTarget !== null }" | |
| 124 | + role="alertdialog" | |
| 125 | + aria-labelledby="revoke-title" | |
| 126 | + > | |
| 127 | + <div class="modal-box"> | |
| 128 | + <h3 id="revoke-title" class="font-bold text-lg">{{ revoke_confirm_title }}</h3> | |
| 129 | + <p class="py-4"> | |
| 130 | + {{ revoke_confirm_body }} | |
| 131 | + <span class="font-mono block mt-2" x-text="revokeTarget ? revokeTarget.split('|')[1] : ''"></span> | |
| 132 | + </p> | |
| 133 | + <form method="POST" action="/admin/fediverse/sessions_remote/revoke" class="modal-action"> | |
| 134 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 135 | + <input type="hidden" name="id" :value="revokeTarget ? revokeTarget.split('|')[0] : ''"> | |
| 136 | + <button type="button" class="btn btn-ghost" @click="revokeTarget = null">{{ modal_cancel }}</button> | |
| 137 | + <button type="submit" class="btn btn-error">{{ modal_confirm }}</button> | |
| 138 | + </form> | |
| 139 | + </div> | |
| 140 | + <form method="dialog" class="modal-backdrop"> | |
| 141 | + <button type="button" @click="revokeTarget = null" aria-label="close">close</button> | |
| 142 | + </form> | |
| 143 | + </dialog> | |
| 144 | + | |
| 145 | + {# Kill switch domain modal (extra-destructive — DaisyUI native dialog) #} | |
| 146 | + <dialog id="kill-domain-modal" class="modal" role="alertdialog" aria-labelledby="kill-modal-title"> | |
| 147 | + <div class="modal-box"> | |
| 148 | + <h3 id="kill-modal-title" class="font-bold text-lg text-error">{{ kill_switch_confirm_title }}</h3> | |
| 149 | + <p class="py-4"> | |
| 150 | + {{ kill_switch_confirm_body }} | |
| 151 | + <span class="font-mono block mt-2" x-text="killDomain"></span> | |
| 152 | + </p> | |
| 153 | + <form method="POST" action="/admin/fediverse/sessions_remote/kill_domain" class="modal-action"> | |
| 154 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 155 | + <input type="hidden" name="domain" :value="killDomain"> | |
| 156 | + <button type="button" class="btn btn-ghost" @click="document.getElementById('kill-domain-modal').close()">{{ modal_cancel }}</button> | |
| 157 | + <button type="submit" class="btn btn-error">{{ modal_confirm }}</button> | |
| 158 | + </form> | |
| 159 | + </div> | |
| 160 | + <form method="dialog" class="modal-backdrop"> | |
| 161 | + <button type="button" aria-label="close">close</button> | |
| 162 | + </form> | |
| 163 | + </dialog> | |
| 164 | +</div> | |
| 165 | +{% endblock %} |
A
crates/rustwarden-fediverse-ui/templates/admin/settings.html
+141
-0
@@ -0,0 +1,141 @@
| 1 | +{% extends "base.html" %} | |
| 2 | + | |
| 3 | +{% block title %}{{ page_title }}{% endblock %} | |
| 4 | + | |
| 5 | +{% block navbar %} | |
| 6 | +{% include "components/navbar.html" %} | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %} }"> | |
| 11 | + | |
| 12 | + {# Breadcrumb (a11y P7: parent landmarks) #} | |
| 13 | + <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 14 | + <ul> | |
| 15 | + <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 16 | + <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 17 | + <li><a href="/admin/fediverse">{{ breadcrumb_federation }}</a></li> | |
| 18 | + <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 19 | + </ul> | |
| 20 | + </nav> | |
| 21 | + | |
| 22 | + {% include "components/admin_tabs.html" %} | |
| 23 | + | |
| 24 | + {# Page header #} | |
| 25 | + <header class="mb-8"> | |
| 26 | + <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 27 | + <p class="text-base-content/70">{{ subtitle }}</p> | |
| 28 | + </header> | |
| 29 | + | |
| 30 | + {# Save toast (Plan UI §3 P3) — auto-dismisses after 4 s #} | |
| 31 | + <div | |
| 32 | + x-show="saved" | |
| 33 | + x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })" | |
| 34 | + x-transition | |
| 35 | + role="status" | |
| 36 | + aria-live="polite" | |
| 37 | + class="alert alert-success mb-6" | |
| 38 | + > | |
| 39 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 40 | + <span>{{ saved_toast }}</span> | |
| 41 | + </div> | |
| 42 | + | |
| 43 | + <form method="POST" action="/admin/fediverse/settings" class="space-y-6 max-w-3xl"> | |
| 44 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 45 | + | |
| 46 | + {# Card: Activation #} | |
| 47 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-activation"> | |
| 48 | + <div class="card-body"> | |
| 49 | + <h2 id="card-activation" class="card-title text-xl mb-4">{{ card_activation }}</h2> | |
| 50 | + <label class="label cursor-pointer justify-start gap-4" for="fediverse_enabled"> | |
| 51 | + <input | |
| 52 | + id="fediverse_enabled" | |
| 53 | + type="checkbox" | |
| 54 | + name="fediverse_enabled" | |
| 55 | + value="true" | |
| 56 | + class="checkbox checkbox-primary" | |
| 57 | + {% if settings.fediverse_enabled == "true" %}checked{% endif %} | |
| 58 | + > | |
| 59 | + <span class="flex flex-col"> | |
| 60 | + <span class="label-text font-semibold">{{ enabled_label }}</span> | |
| 61 | + <span class="label-text-alt text-base-content/60">{{ enabled_desc }}</span> | |
| 62 | + </span> | |
| 63 | + </label> | |
| 64 | + </div> | |
| 65 | + </section> | |
| 66 | + | |
| 67 | + {# Card: Trust mode #} | |
| 68 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-trust"> | |
| 69 | + <div class="card-body"> | |
| 70 | + <h2 id="card-trust" class="card-title text-xl mb-4">{{ card_trust }}</h2> | |
| 71 | + <label class="label" for="fediverse_trust_mode"> | |
| 72 | + <span class="label-text font-semibold">{{ trust_mode_label }}</span> | |
| 73 | + </label> | |
| 74 | + <select | |
| 75 | + id="fediverse_trust_mode" | |
| 76 | + name="fediverse_trust_mode" | |
| 77 | + class="select select-bordered w-full max-w-md" | |
| 78 | + > | |
| 79 | + <option value="allowlist" {% if settings.fediverse_trust_mode == "allowlist" %}selected{% endif %}> | |
| 80 | + {{ trust_mode_allowlist }} | |
| 81 | + </option> | |
| 82 | + <option value="open_with_blocklist" {% if settings.fediverse_trust_mode == "open_with_blocklist" %}selected{% endif %}> | |
| 83 | + {{ trust_mode_open }} | |
| 84 | + </option> | |
| 85 | + </select> | |
| 86 | + </div> | |
| 87 | + </section> | |
| 88 | + | |
| 89 | + {# Card: Interop scope #} | |
| 90 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-interop"> | |
| 91 | + <div class="card-body"> | |
| 92 | + <h2 id="card-interop" class="card-title text-xl mb-4">{{ card_interop }}</h2> | |
| 93 | + <label class="label" for="fediverse_interop_scope"> | |
| 94 | + <span class="label-text font-semibold">{{ interop_scope_label }}</span> | |
| 95 | + </label> | |
| 96 | + <select | |
| 97 | + id="fediverse_interop_scope" | |
| 98 | + name="fediverse_interop_scope" | |
| 99 | + class="select select-bordered w-full max-w-md" | |
| 100 | + > | |
| 101 | + <option value="rustwarden_only" {% if settings.fediverse_interop_scope == "rustwarden_only" %}selected{% endif %}> | |
| 102 | + {{ interop_scope_rustwarden_only }} | |
| 103 | + </option> | |
| 104 | + <option value="rustwarden_plus_universal_read" {% if settings.fediverse_interop_scope == "rustwarden_plus_universal_read" %}selected{% endif %}> | |
| 105 | + {{ interop_scope_rustwarden_plus }} | |
| 106 | + </option> | |
| 107 | + <option value="open" {% if settings.fediverse_interop_scope == "open" %}selected{% endif %}> | |
| 108 | + {{ interop_scope_open }} | |
| 109 | + </option> | |
| 110 | + </select> | |
| 111 | + </div> | |
| 112 | + </section> | |
| 113 | + | |
| 114 | + {# Card: Advanced security #} | |
| 115 | + <section class="card bg-base-100 shadow-xl" aria-labelledby="card-security"> | |
| 116 | + <div class="card-body"> | |
| 117 | + <h2 id="card-security" class="card-title text-xl mb-4">{{ card_security }}</h2> | |
| 118 | + <label class="label cursor-pointer justify-start gap-4" for="fediverse_authorized_fetch"> | |
| 119 | + <input | |
| 120 | + id="fediverse_authorized_fetch" | |
| 121 | + type="checkbox" | |
| 122 | + name="fediverse_authorized_fetch" | |
| 123 | + value="true" | |
| 124 | + class="checkbox checkbox-primary" | |
| 125 | + {% if settings.fediverse_authorized_fetch == "true" %}checked{% endif %} | |
| 126 | + > | |
| 127 | + <span class="flex flex-col"> | |
| 128 | + <span class="label-text font-semibold">{{ authorized_fetch_label }}</span> | |
| 129 | + <span class="label-text-alt text-base-content/60">{{ authorized_fetch_desc }}</span> | |
| 130 | + </span> | |
| 131 | + </label> | |
| 132 | + </div> | |
| 133 | + </section> | |
| 134 | + | |
| 135 | + {# Single primary CTA (Plan UI §3 P1) #} | |
| 136 | + <div class="flex justify-end"> | |
| 137 | + <button type="submit" class="btn btn-primary">{{ save_button }}</button> | |
| 138 | + </div> | |
| 139 | + </form> | |
| 140 | +</div> | |
| 141 | +{% endblock %} |
A
crates/rustwarden-fediverse-ui/templates/admin/team_invites.html
+170
-0
@@ -0,0 +1,170 @@
| 1 | +{% extends "base.html" %} | |
| 2 | + | |
| 3 | +{% block title %}{{ page_title }}{% endblock %} | |
| 4 | + | |
| 5 | +{% block navbar %} | |
| 6 | +{% include "components/navbar.html" %} | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %}, revokeTarget: null }"> | |
| 11 | + | |
| 12 | + <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 13 | + <ul> | |
| 14 | + <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 15 | + <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 16 | + <li><a href="/admin/fediverse">{{ breadcrumb_federation }}</a></li> | |
| 17 | + <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 18 | + </ul> | |
| 19 | + </nav> | |
| 20 | + | |
| 21 | + {% include "components/admin_tabs.html" %} | |
| 22 | + | |
| 23 | + <header class="mb-8"> | |
| 24 | + <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 25 | + <p class="text-base-content/70">{{ subtitle }} <span class="font-mono">{{ team_name }}</span></p> | |
| 26 | + </header> | |
| 27 | + | |
| 28 | + <div | |
| 29 | + x-show="saved" | |
| 30 | + x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })" | |
| 31 | + x-transition | |
| 32 | + role="status" | |
| 33 | + aria-live="polite" | |
| 34 | + class="alert alert-success mb-6" | |
| 35 | + > | |
| 36 | + <span>{{ saved_toast }}</span> | |
| 37 | + </div> | |
| 38 | + | |
| 39 | + {# Add form #} | |
| 40 | + <section class="card bg-base-100 shadow-xl mb-8" aria-labelledby="add-legend"> | |
| 41 | + <div class="card-body"> | |
| 42 | + <h2 id="add-legend" class="card-title text-xl mb-4">{{ add_form_legend }}</h2> | |
| 43 | + <form method="POST" action="/admin/fediverse/invites/{{ team_id }}/add" class="space-y-4"> | |
| 44 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 45 | + | |
| 46 | + <div class="form-control"> | |
| 47 | + <label class="label" for="invite-handle"> | |
| 48 | + <span class="label-text font-semibold">{{ handle_label }}</span> | |
| 49 | + </label> | |
| 50 | + <input | |
| 51 | + id="invite-handle" | |
| 52 | + type="text" | |
| 53 | + name="handle" | |
| 54 | + required | |
| 55 | + autocomplete="off" | |
| 56 | + autocapitalize="off" | |
| 57 | + spellcheck="false" | |
| 58 | + pattern="^@?[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$" | |
| 59 | + class="input input-bordered w-full max-w-md" | |
| 60 | + placeholder="{{ handle_placeholder }}" | |
| 61 | + > | |
| 62 | + </div> | |
| 63 | + | |
| 64 | + <div class="form-control"> | |
| 65 | + <label class="label" for="invite-role"> | |
| 66 | + <span class="label-text font-semibold">{{ role_label }}</span> | |
| 67 | + </label> | |
| 68 | + <select id="invite-role" name="role" required class="select select-bordered w-full max-w-md"> | |
| 69 | + <option value="member">{{ role_member }}</option> | |
| 70 | + <option value="admin">{{ role_admin }}</option> | |
| 71 | + <option value="owner">{{ role_owner }}</option> | |
| 72 | + </select> | |
| 73 | + </div> | |
| 74 | + | |
| 75 | + <div class="flex justify-end"> | |
| 76 | + <button type="submit" class="btn btn-primary">{{ add_button }}</button> | |
| 77 | + </div> | |
| 78 | + </form> | |
| 79 | + </div> | |
| 80 | + </section> | |
| 81 | + | |
| 82 | + {# Table or empty state #} | |
| 83 | + {% if has_rows %} | |
| 84 | + <section class="card bg-base-100 shadow-xl"> | |
| 85 | + <div class="card-body"> | |
| 86 | + <div class="overflow-x-auto"> | |
| 87 | + <table class="table table-zebra"> | |
| 88 | + <thead> | |
| 89 | + <tr> | |
| 90 | + <th>{{ table_handle }}</th> | |
| 91 | + <th>{{ table_role }}</th> | |
| 92 | + <th>{{ table_status }}</th> | |
| 93 | + <th>{{ table_created_at }}</th> | |
| 94 | + <th class="text-right">{{ table_actions }}</th> | |
| 95 | + </tr> | |
| 96 | + </thead> | |
| 97 | + <tbody> | |
| 98 | + {% for row in rows %} | |
| 99 | + <tr> | |
| 100 | + <td class="font-mono">{{ row.invited_handle }}</td> | |
| 101 | + <td><span class="badge badge-ghost">{{ row.role }}</span></td> | |
| 102 | + <td> | |
| 103 | + {% if row.status == "pending" %} | |
| 104 | + <span class="badge badge-warning">{{ row.status }}</span> | |
| 105 | + {% else if row.status == "accepted" %} | |
| 106 | + <span class="badge badge-success">{{ row.status }}</span> | |
| 107 | + {% else if row.status == "rejected" || row.status == "revoked" || row.status == "expired" %} | |
| 108 | + <span class="badge badge-error">{{ row.status }}</span> | |
| 109 | + {% else %} | |
| 110 | + <span class="badge">{{ row.status }}</span> | |
| 111 | + {% endif %} | |
| 112 | + </td> | |
| 113 | + <td class="text-base-content/70">{{ row.created_at }}</td> | |
| 114 | + <td class="text-right"> | |
| 115 | + {% if row.status == "pending" || row.status == "accepted" %} | |
| 116 | + <button | |
| 117 | + type="button" | |
| 118 | + class="btn btn-sm btn-ghost text-error" | |
| 119 | + @click="revokeTarget = '{{ row.id }}|{{ row.invited_handle }}'" | |
| 120 | + aria-haspopup="dialog" | |
| 121 | + > | |
| 122 | + {{ revoke_button }} | |
| 123 | + </button> | |
| 124 | + {% else %} | |
| 125 | + — | |
| 126 | + {% endif %} | |
| 127 | + </td> | |
| 128 | + </tr> | |
| 129 | + {% endfor %} | |
| 130 | + </tbody> | |
| 131 | + </table> | |
| 132 | + </div> | |
| 133 | + </div> | |
| 134 | + </section> | |
| 135 | + {% else %} | |
| 136 | + <section class="card bg-base-200 shadow-inner" aria-label="empty-state"> | |
| 137 | + <div class="card-body items-center text-center py-16"> | |
| 138 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> | |
| 139 | + <h3 class="text-lg font-semibold">{{ empty_title }}</h3> | |
| 140 | + <p class="text-base-content/70 max-w-md">{{ empty_body }}</p> | |
| 141 | + </div> | |
| 142 | + </section> | |
| 143 | + {% endif %} | |
| 144 | + | |
| 145 | + {# Revoke modal #} | |
| 146 | + <dialog | |
| 147 | + class="modal" | |
| 148 | + :class="{ 'modal-open': revokeTarget !== null }" | |
| 149 | + role="alertdialog" | |
| 150 | + aria-labelledby="revoke-title" | |
| 151 | + > | |
| 152 | + <div class="modal-box"> | |
| 153 | + <h3 id="revoke-title" class="font-bold text-lg">{{ revoke_confirm_title }}</h3> | |
| 154 | + <p class="py-4"> | |
| 155 | + {{ revoke_confirm_body }} | |
| 156 | + <span class="font-mono block mt-2" x-text="revokeTarget ? revokeTarget.split('|')[1] : ''"></span> | |
| 157 | + </p> | |
| 158 | + <form method="POST" action="/admin/fediverse/invites/{{ team_id }}/revoke" class="modal-action"> | |
| 159 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 160 | + <input type="hidden" name="id" :value="revokeTarget ? revokeTarget.split('|')[0] : ''"> | |
| 161 | + <button type="button" class="btn btn-ghost" @click="revokeTarget = null">{{ modal_cancel }}</button> | |
| 162 | + <button type="submit" class="btn btn-error">{{ modal_confirm }}</button> | |
| 163 | + </form> | |
| 164 | + </div> | |
| 165 | + <form method="dialog" class="modal-backdrop"> | |
| 166 | + <button type="button" @click="revokeTarget = null" aria-label="close">close</button> | |
| 167 | + </form> | |
| 168 | + </dialog> | |
| 169 | +</div> | |
| 170 | +{% endblock %} |
A
crates/rustwarden-fediverse-ui/templates/admin/teams_for_invites.html
+52
-0
@@ -0,0 +1,52 @@
| 1 | +{% extends "base.html" %} | |
| 2 | + | |
| 3 | +{% block title %}{{ page_title }}{% endblock %} | |
| 4 | + | |
| 5 | +{% block navbar %} | |
| 6 | +{% include "components/navbar.html" %} | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<div class="container mx-auto px-4 py-8"> | |
| 11 | + | |
| 12 | + <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 13 | + <ul> | |
| 14 | + <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 15 | + <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 16 | + <li><a href="/admin/fediverse">{{ breadcrumb_federation }}</a></li> | |
| 17 | + <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 18 | + </ul> | |
| 19 | + </nav> | |
| 20 | + | |
| 21 | + {% include "components/admin_tabs.html" %} | |
| 22 | + | |
| 23 | + <header class="mb-8"> | |
| 24 | + <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 25 | + <p class="text-base-content/70">{{ subtitle }}</p> | |
| 26 | + </header> | |
| 27 | + | |
| 28 | + {% if has_teams %} | |
| 29 | + <section class="card bg-base-100 shadow-xl"> | |
| 30 | + <div class="card-body"> | |
| 31 | + <ul class="menu bg-base-100 w-full"> | |
| 32 | + {% for team in teams %} | |
| 33 | + <li> | |
| 34 | + <a href="/admin/fediverse/invites/{{ team.id }}" class="flex items-center justify-between"> | |
| 35 | + <span class="font-semibold">{{ team.name }}</span> | |
| 36 | + <span class="text-base-content/60 font-mono text-sm">{{ team.slug }}</span> | |
| 37 | + </a> | |
| 38 | + </li> | |
| 39 | + {% endfor %} | |
| 40 | + </ul> | |
| 41 | + </div> | |
| 42 | + </section> | |
| 43 | + {% else %} | |
| 44 | + <section class="card bg-base-100 shadow-xl"> | |
| 45 | + <div class="card-body text-center py-12"> | |
| 46 | + <h2 class="text-xl font-semibold mb-2">{{ empty_title }}</h2> | |
| 47 | + <p class="text-base-content/60">{{ empty_body }}</p> | |
| 48 | + </div> | |
| 49 | + </section> | |
| 50 | + {% endif %} | |
| 51 | +</div> | |
| 52 | +{% endblock %} |
A
crates/rustwarden-fediverse-ui/templates/admin/trust_list.html
+188
-0
@@ -0,0 +1,188 @@
| 1 | +{% extends "base.html" %} | |
| 2 | + | |
| 3 | +{% block title %}{{ page_title }}{% endblock %} | |
| 4 | + | |
| 5 | +{% block navbar %} | |
| 6 | +{% include "components/navbar.html" %} | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %}, deleteTarget: null }"> | |
| 11 | + | |
| 12 | + {# Breadcrumb #} | |
| 13 | + <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 14 | + <ul> | |
| 15 | + <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 16 | + <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 17 | + <li><a href="/admin/fediverse">{{ breadcrumb_federation }}</a></li> | |
| 18 | + <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 19 | + </ul> | |
| 20 | + </nav> | |
| 21 | + | |
| 22 | + {% include "components/admin_tabs.html" %} | |
| 23 | + | |
| 24 | + {# Page header #} | |
| 25 | + <header class="mb-2"> | |
| 26 | + <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 27 | + <p class="text-base-content/70">{{ subtitle }}</p> | |
| 28 | + </header> | |
| 29 | + | |
| 30 | + {# Mode indicator badge #} | |
| 31 | + <div class="mb-6"> | |
| 32 | + <span class="badge {% if is_blocklist %}badge-warning{% else %}badge-success{% endif %} badge-lg"> | |
| 33 | + {{ mode_indicator }} | |
| 34 | + </span> | |
| 35 | + </div> | |
| 36 | + | |
| 37 | + {# Toast on save (Plan UI §3 P3) #} | |
| 38 | + <div | |
| 39 | + x-show="saved" | |
| 40 | + x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })" | |
| 41 | + x-transition | |
| 42 | + role="status" | |
| 43 | + aria-live="polite" | |
| 44 | + class="alert alert-success mb-6" | |
| 45 | + > | |
| 46 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 47 | + <span>{{ saved_toast }}</span> | |
| 48 | + </div> | |
| 49 | + | |
| 50 | + {# Add form #} | |
| 51 | + <section class="card bg-base-100 shadow-xl mb-8" aria-labelledby="add-legend"> | |
| 52 | + <div class="card-body"> | |
| 53 | + <h2 id="add-legend" class="card-title text-xl mb-4">{{ add_form_legend }}</h2> | |
| 54 | + <form method="POST" action="/admin/fediverse/trust_list/add" class="space-y-4"> | |
| 55 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 56 | + | |
| 57 | + <div class="form-control"> | |
| 58 | + <label class="label" for="trust-domain"> | |
| 59 | + <span class="label-text font-semibold">{{ domain_label }}</span> | |
| 60 | + </label> | |
| 61 | + <input | |
| 62 | + id="trust-domain" | |
| 63 | + type="text" | |
| 64 | + name="domain" | |
| 65 | + required | |
| 66 | + autocomplete="off" | |
| 67 | + class="input input-bordered w-full max-w-md" | |
| 68 | + placeholder="{{ domain_placeholder }}" | |
| 69 | + > | |
| 70 | + </div> | |
| 71 | + | |
| 72 | + <div class="form-control"> | |
| 73 | + <label class="label" for="trust-note"> | |
| 74 | + <span class="label-text font-semibold"> | |
| 75 | + {% if is_blocklist %}{{ reason_label }}{% else %}{{ note_label }}{% endif %} | |
| 76 | + </span> | |
| 77 | + </label> | |
| 78 | + <input | |
| 79 | + id="trust-note" | |
| 80 | + type="text" | |
| 81 | + name="note_or_reason" | |
| 82 | + autocomplete="off" | |
| 83 | + class="input input-bordered w-full max-w-md" | |
| 84 | + > | |
| 85 | + </div> | |
| 86 | + | |
| 87 | + {% if is_blocklist %} | |
| 88 | + <div class="form-control"> | |
| 89 | + <label class="label" for="trust-severity"> | |
| 90 | + <span class="label-text font-semibold">{{ severity_label }}</span> | |
| 91 | + </label> | |
| 92 | + <select | |
| 93 | + id="trust-severity" | |
| 94 | + name="severity" | |
| 95 | + required | |
| 96 | + class="select select-bordered w-full max-w-md" | |
| 97 | + > | |
| 98 | + <option value="suspend">{{ severity_suspend }}</option> | |
| 99 | + <option value="silence">{{ severity_silence }}</option> | |
| 100 | + <option value="reject_auth">{{ severity_reject_auth }}</option> | |
| 101 | + </select> | |
| 102 | + </div> | |
| 103 | + {% endif %} | |
| 104 | + | |
| 105 | + <div class="flex justify-end"> | |
| 106 | + <button type="submit" class="btn btn-primary">{{ add_button }}</button> | |
| 107 | + </div> | |
| 108 | + </form> | |
| 109 | + </div> | |
| 110 | + </section> | |
| 111 | + | |
| 112 | + {# Table or empty state #} | |
| 113 | + {% if has_entries %} | |
| 114 | + <section class="card bg-base-100 shadow-xl"> | |
| 115 | + <div class="card-body"> | |
| 116 | + <div class="overflow-x-auto"> | |
| 117 | + <table class="table table-zebra"> | |
| 118 | + <thead> | |
| 119 | + <tr> | |
| 120 | + <th>{{ table_domain }}</th> | |
| 121 | + <th>{{ table_note_or_reason }}</th> | |
| 122 | + {% if is_blocklist %}<th>{{ table_severity }}</th>{% endif %} | |
| 123 | + <th>{{ table_created_at }}</th> | |
| 124 | + <th class="text-right">{{ table_actions }}</th> | |
| 125 | + </tr> | |
| 126 | + </thead> | |
| 127 | + <tbody> | |
| 128 | + {% for entry in entries %} | |
| 129 | + <tr> | |
| 130 | + <td class="font-mono">{{ entry.domain }}</td> | |
| 131 | + <td>{% if entry.has_note_or_reason %}{{ entry.note_or_reason }}{% else %}—{% endif %}</td> | |
| 132 | + {% if is_blocklist %}<td>{{ entry.severity }}</td>{% endif %} | |
| 133 | + <td class="text-base-content/70">{{ entry.created_at }}</td> | |
| 134 | + <td class="text-right"> | |
| 135 | + <button | |
| 136 | + type="button" | |
| 137 | + class="btn btn-sm btn-ghost text-error" | |
| 138 | + @click="deleteTarget = '{{ entry.domain }}'" | |
| 139 | + aria-haspopup="dialog" | |
| 140 | + > | |
| 141 | + {{ delete_button }} | |
| 142 | + </button> | |
| 143 | + </td> | |
| 144 | + </tr> | |
| 145 | + {% endfor %} | |
| 146 | + </tbody> | |
| 147 | + </table> | |
| 148 | + </div> | |
| 149 | + </div> | |
| 150 | + </section> | |
| 151 | + {% else %} | |
| 152 | + {# Empty state (Plan UI §3 P4) #} | |
| 153 | + <section class="card bg-base-200 shadow-inner" aria-label="empty-state"> | |
| 154 | + <div class="card-body items-center text-center py-16"> | |
| 155 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> | |
| 156 | + <h3 class="text-lg font-semibold">{{ empty_title }}</h3> | |
| 157 | + <p class="text-base-content/70 max-w-md">{{ empty_body }}</p> | |
| 158 | + </div> | |
| 159 | + </section> | |
| 160 | + {% endif %} | |
| 161 | + | |
| 162 | + {# Confirmation modal (Plan UI §3 P2). Triggered by `deleteTarget`. #} | |
| 163 | + <dialog | |
| 164 | + class="modal" | |
| 165 | + :class="{ 'modal-open': deleteTarget !== null }" | |
| 166 | + role="alertdialog" | |
| 167 | + aria-labelledby="confirm-title" | |
| 168 | + aria-describedby="confirm-body" | |
| 169 | + > | |
| 170 | + <div class="modal-box"> | |
| 171 | + <h3 id="confirm-title" class="font-bold text-lg">{{ delete_confirm_title }}</h3> | |
| 172 | + <p id="confirm-body" class="py-4"> | |
| 173 | + {{ delete_confirm_body }} | |
| 174 | + <span class="font-mono block mt-2" x-text="deleteTarget"></span> | |
| 175 | + </p> | |
| 176 | + <form method="POST" action="/admin/fediverse/trust_list/delete" class="modal-action"> | |
| 177 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 178 | + <input type="hidden" name="domain" :value="deleteTarget"> | |
| 179 | + <button type="button" class="btn btn-ghost" @click="deleteTarget = null">{{ modal_cancel }}</button> | |
| 180 | + <button type="submit" class="btn btn-error">{{ modal_confirm }}</button> | |
| 181 | + </form> | |
| 182 | + </div> | |
| 183 | + <form method="dialog" class="modal-backdrop"> | |
| 184 | + <button type="button" @click="deleteTarget = null" aria-label="close">close</button> | |
| 185 | + </form> | |
| 186 | + </dialog> | |
| 187 | +</div> | |
| 188 | +{% endblock %} |
A
crates/rustwarden-fediverse-ui/templates/components/admin_tabs.html
+14
-0
@@ -0,0 +1,14 @@
| 1 | +{# Tab bar shared across every `/admin/fediverse/*` screen. #} | |
| 2 | +{# Expects `tabs: Vec<AdminTabDTO>` and `active_tab: String` on the #} | |
| 3 | +{# template context. The `tab-active` class is applied to the entry #} | |
| 4 | +{# whose slug equals `active_tab`. #} | |
| 5 | +<div role="tablist" class="tabs tabs-boxed mb-6" aria-label="federation admin sections"> | |
| 6 | + {% for tab in tabs %} | |
| 7 | + <a | |
| 8 | + role="tab" | |
| 9 | + class="tab{% if tab.slug == active_tab %} tab-active{% endif %}" | |
| 10 | + href="{{ tab.href }}" | |
| 11 | + {% if tab.slug == active_tab %}aria-current="page"{% endif %} | |
| 12 | + >{{ tab.label }}</a> | |
| 13 | + {% endfor %} | |
| 14 | +</div> |
A
crates/rustwarden-fediverse-ui/templates/connected_apps.html
+61
-0
@@ -0,0 +1,61 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{ current_locale }}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ page_title }}</title> | |
| 7 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | +</head> | |
| 10 | +<body class="min-h-screen bg-base-200 px-4 py-8"> | |
| 11 | + <main class="container mx-auto max-w-3xl"> | |
| 12 | + <header class="mb-6"> | |
| 13 | + <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 14 | + <p class="text-base-content/70">{{ subtitle }}</p> | |
| 15 | + </header> | |
| 16 | + | |
| 17 | + {% if has_rows %} | |
| 18 | + <section class="card bg-base-100 shadow-xl mb-6"> | |
| 19 | + <div class="card-body"> | |
| 20 | + <div class="overflow-x-auto"> | |
| 21 | + <table class="table table-zebra"> | |
| 22 | + <thead> | |
| 23 | + <tr> | |
| 24 | + <th>{{ table_domain }}</th> | |
| 25 | + <th>{{ table_count }}</th> | |
| 26 | + <th>{{ table_last_seen }}</th> | |
| 27 | + </tr> | |
| 28 | + </thead> | |
| 29 | + <tbody> | |
| 30 | + {% for row in rows %} | |
| 31 | + <tr> | |
| 32 | + <td class="font-mono">{{ row.domain }}</td> | |
| 33 | + <td>{{ row.session_count }}</td> | |
| 34 | + <td class="text-base-content/70">{{ row.last_seen }}</td> | |
| 35 | + </tr> | |
| 36 | + {% endfor %} | |
| 37 | + </tbody> | |
| 38 | + </table> | |
| 39 | + </div> | |
| 40 | + <div class="alert alert-info mt-4" role="status"> | |
| 41 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 42 | + <span>{{ revoke_note }}</span> | |
| 43 | + </div> | |
| 44 | + </div> | |
| 45 | + </section> | |
| 46 | + {% else %} | |
| 47 | + <section class="card bg-base-200 shadow-inner mb-6" aria-label="empty-state"> | |
| 48 | + <div class="card-body items-center text-center py-12"> | |
| 49 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> | |
| 50 | + <h3 class="text-lg font-semibold">{{ empty_title }}</h3> | |
| 51 | + <p class="text-base-content/70 max-w-md">{{ empty_body }}</p> | |
| 52 | + </div> | |
| 53 | + </section> | |
| 54 | + {% endif %} | |
| 55 | + | |
| 56 | + <div class="flex justify-end"> | |
| 57 | + <a href="/profile" class="btn btn-ghost">{{ back_to_profile }}</a> | |
| 58 | + </div> | |
| 59 | + </main> | |
| 60 | +</body> | |
| 61 | +</html> |
A
crates/rustwarden-fediverse-ui/templates/consent_screen.html
+50
-0
@@ -0,0 +1,50 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{ current_locale }}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ page_title }}</title> | |
| 7 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | +</head> | |
| 10 | +<body class="min-h-screen bg-base-200 flex items-center justify-center px-4"> | |
| 11 | + <main class="card bg-base-100 shadow-xl max-w-lg w-full"> | |
| 12 | + <div class="card-body"> | |
| 13 | + <header class="mb-4"> | |
| 14 | + <h1 class="card-title text-2xl">{{ heading }}</h1> | |
| 15 | + <p class="text-base-content/70 mt-1">{{ explainer }}</p> | |
| 16 | + </header> | |
| 17 | + | |
| 18 | + <dl class="space-y-3 mb-6"> | |
| 19 | + <div> | |
| 20 | + <dt class="text-sm text-base-content/60">{{ field_label_requester }}</dt> | |
| 21 | + <dd class="font-mono text-base">{{ requester_domain }}</dd> | |
| 22 | + </div> | |
| 23 | + <div> | |
| 24 | + <dt class="text-sm text-base-content/60">{{ field_label_handle }}</dt> | |
| 25 | + <dd class="font-mono text-base">{{ handle }}</dd> | |
| 26 | + </div> | |
| 27 | + </dl> | |
| 28 | + | |
| 29 | + {% if already_decided %} | |
| 30 | + <div role="status" aria-live="polite" class="alert alert-info"> | |
| 31 | + <span>{{ already_decided_text }}</span> | |
| 32 | + </div> | |
| 33 | + <div class="card-actions justify-end mt-4"> | |
| 34 | + <a href="/home" class="btn btn-primary">OK</a> | |
| 35 | + </div> | |
| 36 | + {% else %} | |
| 37 | + <form method="POST" action="/fediverse/consent/{{ challenge_id }}" class="card-actions justify-end gap-2"> | |
| 38 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 39 | + <button type="submit" name="decision" value="reject" class="btn btn-ghost text-error"> | |
| 40 | + {{ reject_button }} | |
| 41 | + </button> | |
| 42 | + <button type="submit" name="decision" value="approve" class="btn btn-primary"> | |
| 43 | + {{ approve_button }} | |
| 44 | + </button> | |
| 45 | + </form> | |
| 46 | + {% endif %} | |
| 47 | + </div> | |
| 48 | + </main> | |
| 49 | +</body> | |
| 50 | +</html> |
A
crates/rustwarden-fediverse-ui/templates/error_unauthorized.html
+33
-0
@@ -0,0 +1,33 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{ current_locale }}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <meta name="description" content="{{ subtitle }}"> | |
| 7 | + <title>{{ page_title }}</title> | |
| 8 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 9 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 10 | +</head> | |
| 11 | +<body class="min-h-screen bg-base-200 flex items-center justify-center px-4"> | |
| 12 | + <main class="card bg-base-100 shadow-xl max-w-xl w-full"> | |
| 13 | + <div class="card-body items-center text-center"> | |
| 14 | + {# Icon shifts with reason: alert-triangle for blocked variants, | |
| 15 | + clock for timeout, lock for invalid_signature. #} | |
| 16 | + {% if reason == "timeout" %} | |
| 17 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-warning mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 18 | + {% else if reason == "invalid_signature" %} | |
| 19 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-error mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> | |
| 20 | + {% else %} | |
| 21 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-error mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> | |
| 22 | + {% endif %} | |
| 23 | + | |
| 24 | + <h1 class="card-title text-2xl mb-2">{{ heading }}</h1> | |
| 25 | + <p class="text-base-content/80 mb-6">{{ body }}</p> | |
| 26 | + | |
| 27 | + <div class="card-actions"> | |
| 28 | + <a href="/login" class="btn btn-primary">{{ back_to_login }}</a> | |
| 29 | + </div> | |
| 30 | + </div> | |
| 31 | + </main> | |
| 32 | +</body> | |
| 33 | +</html> |
A
crates/rustwarden-fediverse-ui/templates/invite_landing.html
+75
-0
@@ -0,0 +1,75 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{ current_locale }}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ page_title }}</title> | |
| 7 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | +</head> | |
| 10 | +<body class="min-h-screen bg-base-200 flex items-center justify-center px-4"> | |
| 11 | + <main class="card bg-base-100 shadow-xl max-w-lg w-full"> | |
| 12 | + <div class="card-body"> | |
| 13 | + <header class="mb-4"> | |
| 14 | + <h1 class="card-title text-2xl">{{ heading }}</h1> | |
| 15 | + <p class="text-base-content/70 mt-1">{{ explainer }}</p> | |
| 16 | + </header> | |
| 17 | + | |
| 18 | + <dl class="space-y-3 mb-6"> | |
| 19 | + <div> | |
| 20 | + <dt class="text-sm text-base-content/60">{{ team_label }}</dt> | |
| 21 | + <dd class="font-mono text-base">{{ team_name }}</dd> | |
| 22 | + </div> | |
| 23 | + <div> | |
| 24 | + <dt class="text-sm text-base-content/60">{{ role_label }}</dt> | |
| 25 | + <dd> | |
| 26 | + <span class="badge badge-ghost">{{ role_value }}</span> | |
| 27 | + </dd> | |
| 28 | + </div> | |
| 29 | + <div> | |
| 30 | + <dt class="text-sm text-base-content/60">{{ status_label }}</dt> | |
| 31 | + <dd> | |
| 32 | + {% if status_value == "pending" %} | |
| 33 | + <span class="badge badge-warning">{{ status_value }}</span> | |
| 34 | + {% else if status_value == "accepted" %} | |
| 35 | + <span class="badge badge-success">{{ status_value }}</span> | |
| 36 | + {% else if status_value == "rejected" || status_value == "revoked" || status_value == "expired" %} | |
| 37 | + <span class="badge badge-error">{{ status_value }}</span> | |
| 38 | + {% else %} | |
| 39 | + <span class="badge">{{ status_value }}</span> | |
| 40 | + {% endif %} | |
| 41 | + </dd> | |
| 42 | + </div> | |
| 43 | + </dl> | |
| 44 | + | |
| 45 | + {% if already_decided %} | |
| 46 | + <div role="status" aria-live="polite" class="alert alert-info"> | |
| 47 | + <span>{{ already_decided_text }}</span> | |
| 48 | + </div> | |
| 49 | + <div class="card-actions justify-end mt-4"> | |
| 50 | + <a href="/home" class="btn btn-primary">OK</a> | |
| 51 | + </div> | |
| 52 | + {% else %} | |
| 53 | + {% if !can_accept %} | |
| 54 | + <div role="status" aria-live="polite" class="alert alert-warning mb-4"> | |
| 55 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> | |
| 56 | + <span>{{ cannot_accept_note }}</span> | |
| 57 | + </div> | |
| 58 | + {% endif %} | |
| 59 | + | |
| 60 | + <form method="POST" action="/fediverse/invite/{{ invite_id }}" class="card-actions justify-end gap-2"> | |
| 61 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 62 | + <button type="submit" name="decision" value="reject" class="btn btn-ghost text-error"> | |
| 63 | + {{ reject_button }} | |
| 64 | + </button> | |
| 65 | + {% if can_accept %} | |
| 66 | + <button type="submit" name="decision" value="accept" class="btn btn-primary"> | |
| 67 | + {{ accept_button }} | |
| 68 | + </button> | |
| 69 | + {% endif %} | |
| 70 | + </form> | |
| 71 | + {% endif %} | |
| 72 | + </div> | |
| 73 | + </main> | |
| 74 | +</body> | |
| 75 | +</html> |
A
crates/rustwarden-fediverse-ui/templates/login_form.html
+53
-0
@@ -0,0 +1,53 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{ current_locale }}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <meta name="description" content="{{ subtitle }}"> | |
| 7 | + <title>{{ page_title }}</title> | |
| 8 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 9 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 10 | +</head> | |
| 11 | +<body class="min-h-screen bg-base-200 flex items-center justify-center px-4"> | |
| 12 | + <main class="card bg-base-100 shadow-xl max-w-md w-full"> | |
| 13 | + <div class="card-body"> | |
| 14 | + <header class="mb-4"> | |
| 15 | + <h1 class="card-title text-2xl">{{ heading }}</h1> | |
| 16 | + <p class="text-base-content/70 mt-1">{{ subtitle }}</p> | |
| 17 | + </header> | |
| 18 | + | |
| 19 | + <form method="POST" action="/login/fediverse" class="space-y-4" novalidate> | |
| 20 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 21 | + | |
| 22 | + <div class="form-control"> | |
| 23 | + <label class="label" for="fediverse-handle"> | |
| 24 | + <span class="label-text font-semibold">{{ field_label }}</span> | |
| 25 | + </label> | |
| 26 | + <input | |
| 27 | + id="fediverse-handle" | |
| 28 | + type="text" | |
| 29 | + name="handle" | |
| 30 | + required | |
| 31 | + autocomplete="off" | |
| 32 | + autocapitalize="off" | |
| 33 | + spellcheck="false" | |
| 34 | + inputmode="email" | |
| 35 | + pattern="^@?[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$" | |
| 36 | + class="input input-bordered w-full" | |
| 37 | + placeholder="{{ field_placeholder }}" | |
| 38 | + aria-describedby="fediverse-handle-help" | |
| 39 | + > | |
| 40 | + <div id="fediverse-handle-help" class="label"> | |
| 41 | + <span class="label-text-alt text-base-content/60">{{ field_help }}</span> | |
| 42 | + </div> | |
| 43 | + </div> | |
| 44 | + | |
| 45 | + <div class="card-actions justify-between mt-2"> | |
| 46 | + <a href="/login" class="btn btn-ghost">{{ back_to_login }}</a> | |
| 47 | + <button type="submit" class="btn btn-primary">{{ submit_button }}</button> | |
| 48 | + </div> | |
| 49 | + </form> | |
| 50 | + </div> | |
| 51 | + </main> | |
| 52 | +</body> | |
| 53 | +</html> |
A
crates/rustwarden-fediverse-ui/templates/login_waiting.html
+79
-0
@@ -0,0 +1,79 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{ current_locale }}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <meta name="description" content="{{ subtitle }}"> | |
| 7 | + <title>{{ page_title }}</title> | |
| 8 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 9 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 10 | +</head> | |
| 11 | +<body class="min-h-screen bg-base-200 flex items-center justify-center px-4"> | |
| 12 | + <main class="card bg-base-100 shadow-xl max-w-md w-full" role="status" aria-live="polite"> | |
| 13 | + <div class="card-body items-center text-center py-10"> | |
| 14 | + <span class="loading loading-spinner loading-lg text-primary mb-4"></span> | |
| 15 | + <h1 class="card-title text-2xl mb-2">{{ heading }}</h1> | |
| 16 | + <p class="text-base-content/80 mb-6">{{ explainer }}</p> | |
| 17 | + <a href="/login" class="btn btn-ghost btn-sm">{{ cancel_button }}</a> | |
| 18 | + </div> | |
| 19 | + </main> | |
| 20 | + | |
| 21 | + <script> | |
| 22 | + // RFC 0023 Slice B iter. 3 — Server-Sent Events listener. | |
| 23 | + // Pas de framework JS lourd : EventSource natif suffit. | |
| 24 | + (function() { | |
| 25 | + const challengeId = "{{ challenge_id }}"; | |
| 26 | + const sse = new EventSource("/login/fediverse/sse/" + challengeId); | |
| 27 | + | |
| 28 | + sse.addEventListener("status", function(ev) { | |
| 29 | + let payload; | |
| 30 | + try { | |
| 31 | + payload = JSON.parse(ev.data); | |
| 32 | + } catch (_) { | |
| 33 | + return; | |
| 34 | + } | |
| 35 | + const state = payload.state; | |
| 36 | + if (state === "approved") { | |
| 37 | + // L'IdP a approuvé : appeler /api/v1/auth/login/fediverse/consume/{id} | |
| 38 | + // qui pose le cookie JWT remote, puis redirection /home. | |
| 39 | + fetch("/api/v1/auth/login/fediverse/consume/" + challengeId, { | |
| 40 | + method: "POST", | |
| 41 | + credentials: "same-origin", | |
| 42 | + }) | |
| 43 | + .then(function(r) { | |
| 44 | + if (r.ok) { | |
| 45 | + window.location.href = "/home"; | |
| 46 | + } else { | |
| 47 | + window.location.href = "/login/fediverse/error?reason=unknown"; | |
| 48 | + } | |
| 49 | + }) | |
| 50 | + .catch(function() { | |
| 51 | + window.location.href = "/login/fediverse/error?reason=unknown"; | |
| 52 | + }); | |
| 53 | + sse.close(); | |
| 54 | + } else if (state === "rejected") { | |
| 55 | + sse.close(); | |
| 56 | + window.location.href = "/login/fediverse/error?reason=rejected"; | |
| 57 | + } else if (state === "expired") { | |
| 58 | + sse.close(); | |
| 59 | + window.location.href = "/login/fediverse/error?reason=timeout"; | |
| 60 | + } else if (state === "consumed") { | |
| 61 | + sse.close(); | |
| 62 | + window.location.href = "/home"; | |
| 63 | + } | |
| 64 | + // state === "pending" → rien à faire, on attend le prochain poll. | |
| 65 | + }); | |
| 66 | + | |
| 67 | + sse.onerror = function() { | |
| 68 | + // Connexion perdue ; le serveur va relancer mais on | |
| 69 | + // protège l'UX si le navigateur abandonne après plusieurs | |
| 70 | + // tentatives. | |
| 71 | + sse.close(); | |
| 72 | + setTimeout(function() { | |
| 73 | + window.location.href = "/login/fediverse/error?reason=timeout"; | |
| 74 | + }, 30000); | |
| 75 | + }; | |
| 76 | + })(); | |
| 77 | + </script> | |
| 78 | +</body> | |
| 79 | +</html> |
M
crates/rustwarden-ui/Cargo.toml
+0
-12
@@ -35,21 +35,9 @@ validator = { version = "0.20", features = ["derive"] }
| 35 | 35 | num-format = "0.4.4" |
| 36 | 36 | # Time |
| 37 | 37 | time = { version = "0.3", features = ["macros", "serde"] } |
| 38 | -# Stream construction for SSE handlers (RFC 0023 Slice B iter. 3). | |
| 39 | -# Optional: only pulled when `fediverse` is on. | |
| 40 | -futures = { version = "0.3", optional = true } | |
| 41 | -# ActivityPub identity federation (RFC 0001+) extracted to its own crate. | |
| 42 | -rustwarden-fediverse = { path = "../rustwarden-fediverse", optional = true } | |
| 43 | 38 | |
| 44 | 39 | [features] |
| 45 | 40 | default = [] |
| 46 | -# Federation UI handlers, routes, templates, and i18n strings. | |
| 47 | -# Enables the dependency on `rustwarden-fediverse` (which itself lives in | |
| 48 | -# the workspace). | |
| 49 | -fediverse = [ | |
| 50 | - "dep:rustwarden-fediverse", | |
| 51 | - "dep:futures", | |
| 52 | -] | |
| 53 | 41 | |
| 54 | 42 | [lints] |
| 55 | 43 | workspace = true |
M
crates/rustwarden-ui/locales/de.yml
+37
-0
@@ -733,6 +733,43 @@ fediverse:
| 733 | 733 | empty_title: "Keine entfernten Sitzungen" |
| 734 | 734 | empty_body: "Noch keine föderierte Identität hat diese Instanz kontaktiert, oder alle wurden deaktiviert." |
| 735 | 735 | saved_toast: "Aktion abgeschlossen." |
| 736 | + admin: | |
| 737 | + tabs: | |
| 738 | + settings: "Einstellungen" | |
| 739 | + trust_list: "Vertrauensliste" | |
| 740 | + queue: "Ausgangswarteschlange" | |
| 741 | + sessions_remote: "Entfernte Sitzungen" | |
| 742 | + nodeinfo: "NodeInfo" | |
| 743 | + invites: "Einladungen" | |
| 744 | + hub: | |
| 745 | + title: "ActivityPub-Föderation" | |
| 746 | + subtitle: "Zentrales Dashboard zur Steuerung der Föderation mit anderen Rustwarden- und ActivityPub-kompatiblen Instanzen." | |
| 747 | + card: | |
| 748 | + settings: | |
| 749 | + title: "Einstellungen" | |
| 750 | + desc: "Föderation aktivieren, Vertrauensmodus, Interop-Bereich und Authorized-Fetch wählen." | |
| 751 | + trust_list: | |
| 752 | + title: "Vertrauensliste" | |
| 753 | + desc: "Akzeptierte (Allowlist) oder blockierte (Blocklist) Domains verwalten." | |
| 754 | + queue: | |
| 755 | + title: "Ausgangswarteschlange" | |
| 756 | + desc: "Operator-Dashboard für die ausgehende Lieferwarteschlange (RFC 0011 + RFC 0019)." | |
| 757 | + sessions_remote: | |
| 758 | + title: "Entfernte Sitzungen" | |
| 759 | + desc: "Aktive föderierte Identitäten, Kill-Switch pro Identität oder pro Domain." | |
| 760 | + nodeinfo: | |
| 761 | + title: "NodeInfo" | |
| 762 | + desc: "Humanisierte Statistiken der föderierten Instanz (NodeInfo 2.1)." | |
| 763 | + invites: | |
| 764 | + title: "Einladungen" | |
| 765 | + desc: "Ein entferntes Konto in ein Team einladen (Ansicht pro Team)." | |
| 766 | + teams_for_invites: | |
| 767 | + title: "Team auswählen, um seine Einladungen zu verwalten" | |
| 768 | + breadcrumb: "Einladungen" | |
| 769 | + heading: "Föderierte Einladungen pro Team" | |
| 770 | + subtitle: "Wählen Sie ein Team aus, um Einladungen für entfernte Konten anzuzeigen und zu erstellen." | |
| 771 | + empty_title: "Kein Team" | |
| 772 | + empty_body: "Erstellen Sie zuerst ein Team, um föderierte Konten einladen zu können." | |
| 736 | 773 | |
| 737 | 774 | # NodeInfo-Statistiken (RFC 0024 Slice C iter. 3). Dediziertes |
| 738 | 775 | # Top-Level-Namespace, um `fediverse:` nicht erneut zu öffnen |
M
crates/rustwarden-ui/locales/en.yml
+37
-0
@@ -742,6 +742,43 @@ fediverse:
| 742 | 742 | empty_title: "No remote sessions" |
| 743 | 743 | empty_body: "No federated identity has contacted this instance yet, or all have been disabled." |
| 744 | 744 | saved_toast: "Action completed." |
| 745 | + admin: | |
| 746 | + tabs: | |
| 747 | + settings: "Settings" | |
| 748 | + trust_list: "Trust list" | |
| 749 | + queue: "Outbound queue" | |
| 750 | + sessions_remote: "Remote sessions" | |
| 751 | + nodeinfo: "NodeInfo" | |
| 752 | + invites: "Invitations" | |
| 753 | + hub: | |
| 754 | + title: "ActivityPub Federation" | |
| 755 | + subtitle: "Centralized control panel to drive federation with other Rustwarden and ActivityPub-compatible instances." | |
| 756 | + card: | |
| 757 | + settings: | |
| 758 | + title: "Settings" | |
| 759 | + desc: "Enable federation, choose the trust mode, the interop scope and the authorized-fetch." | |
| 760 | + trust_list: | |
| 761 | + title: "Trust list" | |
| 762 | + desc: "Manage accepted (allowlist) or blocked (blocklist) domains." | |
| 763 | + queue: | |
| 764 | + title: "Outbound queue" | |
| 765 | + desc: "Operator dashboard for the outbound delivery queue (RFC 0011 + RFC 0019)." | |
| 766 | + sessions_remote: | |
| 767 | + title: "Remote sessions" | |
| 768 | + desc: "Active federated identities, kill switch per identity or per domain." | |
| 769 | + nodeinfo: | |
| 770 | + title: "NodeInfo" | |
| 771 | + desc: "Humanized statistics for the federated instance (NodeInfo 2.1)." | |
| 772 | + invites: | |
| 773 | + title: "Invitations" | |
| 774 | + desc: "Invite a remote account to join a team (per-team view)." | |
| 775 | + teams_for_invites: | |
| 776 | + title: "Pick a team to manage its invitations" | |
| 777 | + breadcrumb: "Invitations" | |
| 778 | + heading: "Federated invitations per team" | |
| 779 | + subtitle: "Pick a team to view and create invitations targeting remote accounts." | |
| 780 | + empty_title: "No team" | |
| 781 | + empty_body: "Create a team first so you can invite federated accounts to it." | |
| 745 | 782 | |
| 746 | 783 | # Common (RFC 0024 Slice C iter. 3) |
| 747 | 784 | common: |
M
crates/rustwarden-ui/locales/es.yml
+37
-0
@@ -733,6 +733,43 @@ fediverse:
| 733 | 733 | empty_title: "Sin sesiones remotas" |
| 734 | 734 | empty_body: "Ninguna identidad federada ha contactado aún esta instancia, o todas han sido desactivadas." |
| 735 | 735 | saved_toast: "Acción realizada." |
| 736 | + admin: | |
| 737 | + tabs: | |
| 738 | + settings: "Ajustes" | |
| 739 | + trust_list: "Lista de confianza" | |
| 740 | + queue: "Cola de envío" | |
| 741 | + sessions_remote: "Sesiones remotas" | |
| 742 | + nodeinfo: "NodeInfo" | |
| 743 | + invites: "Invitaciones" | |
| 744 | + hub: | |
| 745 | + title: "Federación ActivityPub" | |
| 746 | + subtitle: "Panel de control centralizado para gestionar la federación con otras instancias Rustwarden y compatibles con ActivityPub." | |
| 747 | + card: | |
| 748 | + settings: | |
| 749 | + title: "Ajustes" | |
| 750 | + desc: "Activar la federación, elegir el modo de confianza, el alcance de interoperabilidad y el authorized-fetch." | |
| 751 | + trust_list: | |
| 752 | + title: "Lista de confianza" | |
| 753 | + desc: "Gestionar dominios aceptados (lista blanca) o bloqueados (lista negra)." | |
| 754 | + queue: | |
| 755 | + title: "Cola de envío" | |
| 756 | + desc: "Panel de operador para la cola de entrega saliente (RFC 0011 + RFC 0019)." | |
| 757 | + sessions_remote: | |
| 758 | + title: "Sesiones remotas" | |
| 759 | + desc: "Identidades federadas activas, kill switch por identidad o por dominio." | |
| 760 | + nodeinfo: | |
| 761 | + title: "NodeInfo" | |
| 762 | + desc: "Estadísticas humanizadas de la instancia federada (NodeInfo 2.1)." | |
| 763 | + invites: | |
| 764 | + title: "Invitaciones" | |
| 765 | + desc: "Invitar a una cuenta remota a unirse a un equipo (vista por equipo)." | |
| 766 | + teams_for_invites: | |
| 767 | + title: "Elegir un equipo para gestionar sus invitaciones" | |
| 768 | + breadcrumb: "Invitaciones" | |
| 769 | + heading: "Invitaciones federadas por equipo" | |
| 770 | + subtitle: "Seleccione un equipo para ver y crear invitaciones a cuentas remotas." | |
| 771 | + empty_title: "Sin equipos" | |
| 772 | + empty_body: "Cree primero un equipo para poder invitar cuentas federadas." | |
| 736 | 773 | |
| 737 | 774 | # Estadísticas NodeInfo (RFC 0024 Slice C iter. 3). Namespace de nivel |
| 738 | 775 | # superior dedicado para no reabrir `fediverse:` (que sobrescribiría |
M
crates/rustwarden-ui/locales/fr.yml
+37
-0
@@ -842,6 +842,43 @@ fediverse:
| 842 | 842 | empty_title: "Aucune session distante" |
| 843 | 843 | empty_body: "Aucune identité fédérée n'a encore contacté cette instance, ou toutes ont été désactivées." |
| 844 | 844 | saved_toast: "Action effectuée." |
| 845 | + admin: | |
| 846 | + tabs: | |
| 847 | + settings: "Réglages" | |
| 848 | + trust_list: "Liste de confiance" | |
| 849 | + queue: "File d'envoi" | |
| 850 | + sessions_remote: "Sessions distantes" | |
| 851 | + nodeinfo: "NodeInfo" | |
| 852 | + invites: "Invitations" | |
| 853 | + hub: | |
| 854 | + title: "Fédération ActivityPub" | |
| 855 | + subtitle: "Tableau de bord centralisé pour piloter la fédération avec d'autres instances Rustwarden et compatibles ActivityPub." | |
| 856 | + card: | |
| 857 | + settings: | |
| 858 | + title: "Réglages" | |
| 859 | + desc: "Activer la fédération, choisir le mode de confiance, le périmètre d'interopérabilité et l'authorized-fetch." | |
| 860 | + trust_list: | |
| 861 | + title: "Liste de confiance" | |
| 862 | + desc: "Gérer les domaines acceptés (allowlist) ou bloqués (blocklist)." | |
| 863 | + queue: | |
| 864 | + title: "File d'envoi" | |
| 865 | + desc: "Tableau de bord opérateur pour la file de livraison sortante (RFC 0011 + RFC 0019)." | |
| 866 | + sessions_remote: | |
| 867 | + title: "Sessions distantes" | |
| 868 | + desc: "Identités fédérées actives, kill switch par identité ou par domaine." | |
| 869 | + nodeinfo: | |
| 870 | + title: "NodeInfo" | |
| 871 | + desc: "Statistiques humaines de l'instance fédérée (NodeInfo 2.1)." | |
| 872 | + invites: | |
| 873 | + title: "Invitations" | |
| 874 | + desc: "Inviter un compte distant à rejoindre une équipe (vue par équipe)." | |
| 875 | + teams_for_invites: | |
| 876 | + title: "Choisir une équipe pour gérer ses invitations" | |
| 877 | + breadcrumb: "Invitations" | |
| 878 | + heading: "Invitations fédérées par équipe" | |
| 879 | + subtitle: "Sélectionnez une équipe pour voir et créer des invitations vers des comptes distants." | |
| 880 | + empty_title: "Aucune équipe" | |
| 881 | + empty_body: "Créez d'abord une équipe pour pouvoir y inviter des comptes fédérés." | |
| 845 | 882 | |
| 846 | 883 | # Communs (RFC 0024 Slice C iter. 3) |
| 847 | 884 | common: |
M
crates/rustwarden-ui/locales/it.yml
+37
-0
@@ -840,6 +840,43 @@ fediverse:
| 840 | 840 | empty_title: "Nessuna sessione remota" |
| 841 | 841 | empty_body: "Nessuna identità federata ha ancora contattato questa istanza, o tutte sono state disattivate." |
| 842 | 842 | saved_toast: "Azione completata." |
| 843 | + admin: | |
| 844 | + tabs: | |
| 845 | + settings: "Impostazioni" | |
| 846 | + trust_list: "Lista di fiducia" | |
| 847 | + queue: "Coda di invio" | |
| 848 | + sessions_remote: "Sessioni remote" | |
| 849 | + nodeinfo: "NodeInfo" | |
| 850 | + invites: "Inviti" | |
| 851 | + hub: | |
| 852 | + title: "Federazione ActivityPub" | |
| 853 | + subtitle: "Pannello di controllo centralizzato per gestire la federazione con altre istanze Rustwarden e compatibili ActivityPub." | |
| 854 | + card: | |
| 855 | + settings: | |
| 856 | + title: "Impostazioni" | |
| 857 | + desc: "Attivare la federazione, scegliere la modalità di fiducia, l'ambito di interoperabilità e l'authorized-fetch." | |
| 858 | + trust_list: | |
| 859 | + title: "Lista di fiducia" | |
| 860 | + desc: "Gestire i domini accettati (allowlist) o bloccati (blocklist)." | |
| 861 | + queue: | |
| 862 | + title: "Coda di invio" | |
| 863 | + desc: "Dashboard operatore per la coda di consegna in uscita (RFC 0011 + RFC 0019)." | |
| 864 | + sessions_remote: | |
| 865 | + title: "Sessioni remote" | |
| 866 | + desc: "Identità federate attive, kill switch per identità o per dominio." | |
| 867 | + nodeinfo: | |
| 868 | + title: "NodeInfo" | |
| 869 | + desc: "Statistiche umanizzate dell'istanza federata (NodeInfo 2.1)." | |
| 870 | + invites: | |
| 871 | + title: "Inviti" | |
| 872 | + desc: "Invitare un account remoto a unirsi a un team (vista per team)." | |
| 873 | + teams_for_invites: | |
| 874 | + title: "Scegliere un team per gestire i suoi inviti" | |
| 875 | + breadcrumb: "Inviti" | |
| 876 | + heading: "Inviti federati per team" | |
| 877 | + subtitle: "Selezionare un team per visualizzare e creare inviti verso account remoti." | |
| 878 | + empty_title: "Nessun team" | |
| 879 | + empty_body: "Creare prima un team per poter invitare account federati." | |
| 843 | 880 | |
| 844 | 881 | # Statistiche NodeInfo (RFC 0024 Slice C iter. 3). Namespace di primo |
| 845 | 882 | # livello dedicato per non riaprire `fediverse:` (che sovrascriverebbe |
M
crates/rustwarden-ui/locales/pt.yml
+37
-0
@@ -840,6 +840,43 @@ fediverse:
| 840 | 840 | empty_title: "Sem sessões remotas" |
| 841 | 841 | empty_body: "Nenhuma identidade federada contatou ainda esta instância, ou todas foram desativadas." |
| 842 | 842 | saved_toast: "Ação realizada." |
| 843 | + admin: | |
| 844 | + tabs: | |
| 845 | + settings: "Configurações" | |
| 846 | + trust_list: "Lista de confiança" | |
| 847 | + queue: "Fila de envio" | |
| 848 | + sessions_remote: "Sessões remotas" | |
| 849 | + nodeinfo: "NodeInfo" | |
| 850 | + invites: "Convites" | |
| 851 | + hub: | |
| 852 | + title: "Federação ActivityPub" | |
| 853 | + subtitle: "Painel de controle centralizado para gerenciar a federação com outras instâncias Rustwarden e compatíveis com ActivityPub." | |
| 854 | + card: | |
| 855 | + settings: | |
| 856 | + title: "Configurações" | |
| 857 | + desc: "Ativar a federação, escolher o modo de confiança, o escopo de interoperabilidade e o authorized-fetch." | |
| 858 | + trust_list: | |
| 859 | + title: "Lista de confiança" | |
| 860 | + desc: "Gerenciar domínios aceitos (allowlist) ou bloqueados (blocklist)." | |
| 861 | + queue: | |
| 862 | + title: "Fila de envio" | |
| 863 | + desc: "Painel de operador para a fila de entrega de saída (RFC 0011 + RFC 0019)." | |
| 864 | + sessions_remote: | |
| 865 | + title: "Sessões remotas" | |
| 866 | + desc: "Identidades federadas ativas, kill switch por identidade ou por domínio." | |
| 867 | + nodeinfo: | |
| 868 | + title: "NodeInfo" | |
| 869 | + desc: "Estatísticas humanizadas da instância federada (NodeInfo 2.1)." | |
| 870 | + invites: | |
| 871 | + title: "Convites" | |
| 872 | + desc: "Convidar uma conta remota para se juntar a uma equipe (visão por equipe)." | |
| 873 | + teams_for_invites: | |
| 874 | + title: "Escolher uma equipe para gerenciar seus convites" | |
| 875 | + breadcrumb: "Convites" | |
| 876 | + heading: "Convites federados por equipe" | |
| 877 | + subtitle: "Selecione uma equipe para visualizar e criar convites para contas remotas." | |
| 878 | + empty_title: "Nenhuma equipe" | |
| 879 | + empty_body: "Crie primeiro uma equipe para poder convidar contas federadas." | |
| 843 | 880 | |
| 844 | 881 | # Estatísticas NodeInfo (RFC 0024 Slice C iter. 3). Namespace de nível |
| 845 | 882 | # superior dedicado para não reabrir `fediverse:` (que sobrescreveria |
A
crates/rustwarden-ui/src/dto/dashboard_card.rs
+11
-0
@@ -0,0 +1,11 @@
| 1 | +//! Dashboard card DTO injected by companion crates (e.g. | |
| 2 | +//! `rustwarden-fediverse-ui`) to extend the admin dashboard without | |
| 3 | +//! coupling `rustwarden-ui` to optional features. | |
| 4 | +//! | |
| 5 | +//! The canonical type lives in `rustwarden-core` (cf. | |
| 6 | +//! [`rustwarden_core::dashboard::DashboardCardDTO`]) so the | |
| 7 | +//! `RustwardenBuilder` can carry it without depending on | |
| 8 | +//! `rustwarden-ui`. This module re-exports it under the historical | |
| 9 | +//! `crate::dto::DashboardCardDTO` path for backward compatibility. | |
| 10 | + | |
| 11 | +pub use rustwarden_core::dashboard::DashboardCardDTO; |
M
crates/rustwarden-ui/src/dto/mod.rs
+3
-0
@@ -6,6 +6,8 @@ pub mod admin_dto;
| 6 | 6 | pub mod auth_dto; |
| 7 | 7 | /// DTOs for breadcrumb navigation rendering. |
| 8 | 8 | pub mod breadcrumb_dto; |
| 9 | +/// DTO for dashboard cards injected by companion crates. | |
| 10 | +pub mod dashboard_card; | |
| 9 | 11 | |
| 10 | 12 | /// DTOs for email delivery status and password-reset flows. |
| 11 | 13 | pub mod email_dto; |
@@ -28,6 +30,7 @@ pub use admin_dto::{
| 28 | 30 | }; |
| 29 | 31 | pub use auth_dto::*; |
| 30 | 32 | pub use breadcrumb_dto::*; |
| 33 | +pub use dashboard_card::DashboardCardDTO; | |
| 31 | 34 | pub use email_dto::*; |
| 32 | 35 | pub use home_dto::*; |
| 33 | 36 | pub use navigation_dto::*; |
D
crates/rustwarden-ui/src/handlers/admin_fediverse.rs
+0
-1695
@@ -1,1695 +0,0 @@
| 1 | -//! Admin handlers for the Fediverse UI foundations slice (RFC 0021). | |
| 2 | -//! | |
| 3 | -//! Ships two endpoints behind the standard `admin_required` middleware: | |
| 4 | -//! | |
| 5 | -//! - `GET /admin/fediverse/settings` renders a form pre-filled with the | |
| 6 | -//! four runtime toggles persisted in `app_settings`: | |
| 7 | -//! `fediverse_enabled`, `fediverse_trust_mode`, | |
| 8 | -//! `fediverse_interop_scope`, `fediverse_authorized_fetch`. | |
| 9 | -//! - `POST /admin/fediverse/settings` applies the form (CSRF-checked) | |
| 10 | -//! and redirects back to the GET handler with a flash-style toast | |
| 11 | -//! parameter. | |
| 12 | -//! | |
| 13 | -//! All labels are pulled from `fediverse.*` keys in `locales/*.yml`. | |
| 14 | -//! The trust-list screen is delivered in a follow-up patch within the | |
| 15 | -//! same RFC 0021 slice. | |
| 16 | - | |
| 17 | -use std::sync::Arc; | |
| 18 | - | |
| 19 | -use askama::Template; | |
| 20 | -use axum::extract::{Extension, Form, State}; | |
| 21 | -use axum::response::{IntoResponse, Redirect}; | |
| 22 | -use sea_orm::DatabaseConnection; | |
| 23 | -use serde::Deserialize; | |
| 24 | -use tower_sessions::Session; | |
| 25 | - | |
| 26 | -use rustwarden_core::auth::jwt::Claims; | |
| 27 | -use rustwarden_core::error::AppError; | |
| 28 | -use rustwarden_core::i18n::extractor::Locale; | |
| 29 | -use rustwarden_core::i18n::service::I18nService; | |
| 30 | -use rustwarden_core::middleware::csrf; | |
| 31 | -use rustwarden_core::services::app_settings_service::AppSettingsService; | |
| 32 | -use rustwarden_fediverse::services::fediverse_trust_service::{ | |
| 33 | - BlockSeverity, FediverseTrustService, TrustMode, | |
| 34 | -}; | |
| 35 | - | |
| 36 | -use rustwarden_fediverse::models::fediverse_outbound_queue; | |
| 37 | -use rustwarden_fediverse::models::remote_identity; | |
| 38 | -use rustwarden_core::models::user as user_model; | |
| 39 | -use rustwarden_fediverse::models::fediverse_pending_invite; | |
| 40 | -use rustwarden_core::models::team; | |
| 41 | -use rustwarden_fediverse::services::fediverse_invite_service::{ | |
| 42 | - FediverseInviteService, InviteStatus, | |
| 43 | -}; | |
| 44 | -use rustwarden_core::services::team_service::TeamRole; | |
| 45 | -use rustwarden_fediverse::services::fediverse_outbound_service::OutboundStatus; | |
| 46 | -use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect}; | |
| 47 | - | |
| 48 | -use crate::dto::NavigationDTO; | |
| 49 | -use crate::template_helpers::build_base_context_with_claims; | |
| 50 | - | |
| 51 | -/// Navbar entry consumed by `components/navigation.html` (admin pages). | |
| 52 | -/// | |
| 53 | -/// Mirrors the private struct used in `admin_handler` so this file | |
| 54 | -/// stays standalone — duplication is intentional until a shared | |
| 55 | -/// `AdminLayoutContext` lands (deferred to a generic refactor). | |
| 56 | -#[derive(Debug, Clone)] | |
| 57 | -#[allow(dead_code)] | |
| 58 | -pub struct NavItem { | |
| 59 | - /// Stable identifier; matched against the active page id. | |
| 60 | - pub id: String, | |
| 61 | - /// Visible label (already i18n-translated). | |
| 62 | - pub label: String, | |
| 63 | - /// CSS / heroicon name. | |
| 64 | - pub icon: String, | |
| 65 | - /// Absolute URL the entry points to. | |
| 66 | - pub url: String, | |
| 67 | - /// `true` when this entry corresponds to the current page. | |
| 68 | - pub active: bool, | |
| 69 | - /// `true` when the entry is clickable. | |
| 70 | - pub enabled: bool, | |
| 71 | - /// Badge text (empty string for none). | |
| 72 | - pub badge: String, | |
| 73 | - /// Whether to render the badge. | |
| 74 | - pub has_badge: bool, | |
| 75 | - /// Tooltip shown when `enabled` is false. | |
| 76 | - pub disabled_message: String, | |
| 77 | - /// Whether to render the disabled-tooltip. | |
| 78 | - pub has_disabled_message: bool, | |
| 79 | -} | |
| 80 | - | |
| 81 | -async fn build_fediverse_admin_nav( | |
| 82 | - roles: &[String], | |
| 83 | - i18n_service: &Arc<I18nService>, | |
| 84 | - locale: &Locale, | |
| 85 | - db: &DatabaseConnection, | |
| 86 | -) -> Vec<NavItem> { | |
| 87 | - let navigation = NavigationDTO::for_user(roles, i18n_service, locale, Some(db)).await; | |
| 88 | - navigation | |
| 89 | - .items | |
| 90 | - .iter() | |
| 91 | - .map(|item| NavItem { | |
| 92 | - id: item.id.clone(), | |
| 93 | - label: item.label.clone(), | |
| 94 | - icon: item.icon.clone(), | |
| 95 | - url: item.url.clone(), | |
| 96 | - active: item.id == "admin", | |
| 97 | - enabled: item.enabled, | |
| 98 | - badge: item.badge.clone(), | |
| 99 | - has_badge: item.has_badge, | |
| 100 | - disabled_message: item.disabled_message.clone(), | |
| 101 | - has_disabled_message: item.has_disabled_message, | |
| 102 | - }) | |
| 103 | - .collect() | |
| 104 | -} | |
| 105 | - | |
| 106 | -/// Form payload for `POST /admin/fediverse/settings`. | |
| 107 | -/// | |
| 108 | -/// Checkboxes are absent from the body when unchecked, hence the | |
| 109 | -/// `Option<String>` pattern + presence test rather than `bool`. | |
| 110 | -#[derive(Debug, Deserialize)] | |
| 111 | -pub struct UpdateFediverseSettingsForm { | |
| 112 | - /// CSRF token from the hidden form field. | |
| 113 | - pub csrf_token: String, | |
| 114 | - /// Present only when the toggle is checked. | |
| 115 | - #[serde(default)] | |
| 116 | - pub fediverse_enabled: Option<String>, | |
| 117 | - /// Selector value: `allowlist` or `open_with_blocklist`. | |
| 118 | - pub fediverse_trust_mode: String, | |
| 119 | - /// Selector value: `rustwarden_only`, `rustwarden_plus_universal_read`, | |
| 120 | - /// or `open`. | |
| 121 | - pub fediverse_interop_scope: String, | |
| 122 | - /// Present only when the toggle is checked. | |
| 123 | - #[serde(default)] | |
| 124 | - pub fediverse_authorized_fetch: Option<String>, | |
| 125 | -} | |
| 126 | - | |
| 127 | -/// Subset of `app_settings` rendered into the settings template. | |
| 128 | -#[derive(Debug, Clone)] | |
| 129 | -pub struct FediverseSettingsCtx { | |
| 130 | - /// `"true"` when ActivityPub federation is active runtime-wide. | |
| 131 | - pub fediverse_enabled: String, | |
| 132 | - /// `allowlist` (default) or `open_with_blocklist`. | |
| 133 | - pub fediverse_trust_mode: String, | |
| 134 | - /// Interop scope selector value. | |
| 135 | - pub fediverse_interop_scope: String, | |
| 136 | - /// `"true"` when authorized-fetch (RFC 0017) is required. | |
| 137 | - pub fediverse_authorized_fetch: String, | |
| 138 | -} | |
| 139 | - | |
| 140 | -/// Askama template for `GET /admin/fediverse/settings`. | |
| 141 | -#[derive(Template)] | |
| 142 | -#[template(path = "admin/fediverse/settings.html")] | |
| 143 | -#[allow(dead_code)] | |
| 144 | -pub struct FediverseSettingsTemplate { | |
| 145 | - /// Browser tab + page title. | |
| 146 | - pub page_title: String, | |
| 147 | - /// CSRF token rendered as hidden input. | |
| 148 | - pub csrf_token: String, | |
| 149 | - /// Application display name. | |
| 150 | - pub app_name: String, | |
| 151 | - /// Application version (footer). | |
| 152 | - pub app_version: String, | |
| 153 | - /// Application domain (header). | |
| 154 | - pub app_domain: String, | |
| 155 | - /// Short app description (header). | |
| 156 | - pub app_description: String, | |
| 157 | - /// Username for the navbar avatar. | |
| 158 | - pub username: String, | |
| 159 | - /// Email for the navbar tooltip. | |
| 160 | - pub email: String, | |
| 161 | - /// Two-letter avatar initials. | |
| 162 | - pub initials: String, | |
| 163 | - /// CSS class for avatar background. | |
| 164 | - pub avatar_color: String, | |
| 165 | - /// Comma-joined roles for the navbar tooltip. | |
| 166 | - pub roles_text: String, | |
| 167 | - /// Whether the user has a pending password change banner. | |
| 168 | - pub has_pending_password_change: bool, | |
| 169 | - /// Navigation items for the navbar (built from `NavigationDTO`). | |
| 170 | - pub nav_items: Vec<NavItem>, | |
| 171 | - /// User menu — profile entry label. | |
| 172 | - pub user_menu_profile: String, | |
| 173 | - /// User menu — logout entry label. | |
| 174 | - pub user_menu_logout: String, | |
| 175 | - /// Banner displayed when `?saved=1` is in the URL. | |
| 176 | - pub saved_flash: bool, | |
| 177 | - /// Pre-filled form values. | |
| 178 | - pub settings: FediverseSettingsCtx, | |
| 179 | - | |
| 180 | - /// Heading "Réglages fédération". | |
| 181 | - pub heading: String, | |
| 182 | - /// Subtitle explaining the page. | |
| 183 | - pub subtitle: String, | |
| 184 | - /// Card title "Activation". | |
| 185 | - pub card_activation: String, | |
| 186 | - /// Card title "Confiance". | |
| 187 | - pub card_trust: String, | |
| 188 | - /// Card title "Interopérabilité". | |
| 189 | - pub card_interop: String, | |
| 190 | - /// Card title "Sécurité avancée". | |
| 191 | - pub card_security: String, | |
| 192 | - /// Toggle label. | |
| 193 | - pub enabled_label: String, | |
| 194 | - /// Toggle help text. | |
| 195 | - pub enabled_desc: String, | |
| 196 | - /// trust_mode label. | |
| 197 | - pub trust_mode_label: String, | |
| 198 | - /// trust_mode option: allowlist. | |
| 199 | - pub trust_mode_allowlist: String, | |
| 200 | - /// trust_mode option: open_with_blocklist. | |
| 201 | - pub trust_mode_open: String, | |
| 202 | - /// interop_scope label. | |
| 203 | - pub interop_scope_label: String, | |
| 204 | - /// interop_scope option: rustwarden_only. | |
| 205 | - pub interop_scope_rustwarden_only: String, | |
| 206 | - /// interop_scope option: rustwarden_plus_universal_read. | |
| 207 | - pub interop_scope_rustwarden_plus: String, | |
| 208 | - /// interop_scope option: open. | |
| 209 | - pub interop_scope_open: String, | |
| 210 | - /// authorized_fetch label. | |
| 211 | - pub authorized_fetch_label: String, | |
| 212 | - /// authorized_fetch help text. | |
| 213 | - pub authorized_fetch_desc: String, | |
| 214 | - /// Submit button label. | |
| 215 | - pub save_button: String, | |
| 216 | - /// Toast text shown after a successful save. | |
| 217 | - pub saved_toast: String, | |
| 218 | - /// Breadcrumb segment: home. | |
| 219 | - pub breadcrumb_home: String, | |
| 220 | - /// Breadcrumb segment: admin parent. | |
| 221 | - pub breadcrumb_admin: String, | |
| 222 | - /// Breadcrumb segment: current page (Federation). | |
| 223 | - pub breadcrumb_self: String, | |
| 224 | -} | |
| 225 | - | |
| 226 | -rustwarden_core::impl_template_response!(FediverseSettingsTemplate); | |
| 227 | - | |
| 228 | -/// Allowed values for `fediverse_trust_mode` (RFC 0004). | |
| 229 | -const TRUST_MODES: &[&str] = &["allowlist", "open_with_blocklist"]; | |
| 230 | -/// Allowed values for `fediverse_interop_scope` (master plan §2.1). | |
| 231 | -const INTEROP_SCOPES: &[&str] = &[ | |
| 232 | - "rustwarden_only", | |
| 233 | - "rustwarden_plus_universal_read", | |
| 234 | - "open", | |
| 235 | -]; | |
| 236 | - | |
| 237 | -/// Render `GET /admin/fediverse/settings`. Pre-fills the form with the | |
| 238 | -/// current `app_settings` rows and shows a one-shot toast when the | |
| 239 | -/// referrer included `?saved=1`. | |
| 240 | -pub async fn get_settings( | |
| 241 | - Extension(claims): Extension<Claims>, | |
| 242 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 243 | - Extension(locale): Extension<Locale>, | |
| 244 | - State(db): State<DatabaseConnection>, | |
| 245 | - session: Session, | |
| 246 | - axum::extract::Query(query): axum::extract::Query<SavedQuery>, | |
| 247 | -) -> Result<impl IntoResponse, AppError> { | |
| 248 | - let locale_str = locale.as_str(); | |
| 249 | - let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 250 | - let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 251 | - | |
| 252 | - let all_settings = AppSettingsService::get_all_settings(&db).await?; | |
| 253 | - let get_val = |key: &str, default: &str| -> String { | |
| 254 | - all_settings | |
| 255 | - .iter() | |
| 256 | - .find(|s| s.key == key) | |
| 257 | - .map(|s| s.value.clone()) | |
| 258 | - .unwrap_or_else(|| default.to_string()) | |
| 259 | - }; | |
| 260 | - | |
| 261 | - let settings_ctx = FediverseSettingsCtx { | |
| 262 | - fediverse_enabled: get_val("fediverse_enabled", "false"), | |
| 263 | - fediverse_trust_mode: get_val("fediverse_trust_mode", "allowlist"), | |
| 264 | - fediverse_interop_scope: get_val("fediverse_interop_scope", "rustwarden_only"), | |
| 265 | - fediverse_authorized_fetch: get_val("fediverse_authorized_fetch", "false"), | |
| 266 | - }; | |
| 267 | - | |
| 268 | - let t = |k: &str| { | |
| 269 | - let i18n = i18n_service.clone(); | |
| 270 | - let key = k.to_string(); | |
| 271 | - let loc = locale_str.to_string(); | |
| 272 | - async move { i18n.translate(&key, &loc).await } | |
| 273 | - }; | |
| 274 | - | |
| 275 | - let template = FediverseSettingsTemplate { | |
| 276 | - page_title: t("fediverse.settings.title").await, | |
| 277 | - csrf_token, | |
| 278 | - app_name: base_context.app_name, | |
| 279 | - app_version: base_context.app_version, | |
| 280 | - app_domain: base_context.app_domain, | |
| 281 | - app_description: base_context.app_description, | |
| 282 | - username: base_context.username, | |
| 283 | - email: base_context.email, | |
| 284 | - initials: base_context.initials, | |
| 285 | - avatar_color: base_context.avatar_color, | |
| 286 | - roles_text: base_context.roles.join(", "), | |
| 287 | - has_pending_password_change: false, | |
| 288 | - nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 289 | - .await, | |
| 290 | - user_menu_profile: t("user_menu.profile").await, | |
| 291 | - user_menu_logout: t("user_menu.logout").await, | |
| 292 | - saved_flash: query.saved.unwrap_or(0) == 1, | |
| 293 | - settings: settings_ctx, | |
| 294 | - heading: t("fediverse.settings.heading").await, | |
| 295 | - subtitle: t("fediverse.settings.subtitle").await, | |
| 296 | - card_activation: t("fediverse.settings.card_activation").await, | |
| 297 | - card_trust: t("fediverse.settings.card_trust").await, | |
| 298 | - card_interop: t("fediverse.settings.card_interop").await, | |
| 299 | - card_security: t("fediverse.settings.card_security").await, | |
| 300 | - enabled_label: t("fediverse.settings.enabled_label").await, | |
| 301 | - enabled_desc: t("fediverse.settings.enabled_desc").await, | |
| 302 | - trust_mode_label: t("fediverse.settings.trust_mode_label").await, | |
| 303 | - trust_mode_allowlist: t("fediverse.settings.trust_mode_allowlist").await, | |
| 304 | - trust_mode_open: t("fediverse.settings.trust_mode_open").await, | |
| 305 | - interop_scope_label: t("fediverse.settings.interop_scope_label").await, | |
| 306 | - interop_scope_rustwarden_only: t("fediverse.settings.interop_scope_rustwarden_only") | |
| 307 | - .await, | |
| 308 | - interop_scope_rustwarden_plus: t("fediverse.settings.interop_scope_rustwarden_plus") | |
| 309 | - .await, | |
| 310 | - interop_scope_open: t("fediverse.settings.interop_scope_open").await, | |
| 311 | - authorized_fetch_label: t("fediverse.settings.authorized_fetch_label").await, | |
| 312 | - authorized_fetch_desc: t("fediverse.settings.authorized_fetch_desc").await, | |
| 313 | - save_button: t("fediverse.settings.save_button").await, | |
| 314 | - saved_toast: t("fediverse.settings.saved_toast").await, | |
| 315 | - breadcrumb_home: t("breadcrumb.home").await, | |
| 316 | - breadcrumb_admin: t("breadcrumb.admin").await, | |
| 317 | - breadcrumb_self: t("fediverse.settings.breadcrumb").await, | |
| 318 | - }; | |
| 319 | - | |
| 320 | - Ok(template) | |
| 321 | -} | |
| 322 | - | |
| 323 | -/// Optional `?saved=1` query parameter. | |
| 324 | -#[derive(Debug, Deserialize)] | |
| 325 | -pub struct SavedQuery { | |
| 326 | - /// `1` after a successful POST → triggers the success toast. | |
| 327 | - pub saved: Option<u8>, | |
| 328 | -} | |
| 329 | - | |
| 330 | -/// Apply `POST /admin/fediverse/settings`. Validates CSRF + selector | |
| 331 | -/// values, persists 4 keys via [`AppSettingsService`], and redirects | |
| 332 | -/// back to the GET handler with `?saved=1`. | |
| 333 | -pub async fn update_settings( | |
| 334 | - Extension(claims): Extension<Claims>, | |
| 335 | - session: Session, | |
| 336 | - State(db): State<DatabaseConnection>, | |
| 337 | - Form(form): Form<UpdateFediverseSettingsForm>, | |
| 338 | -) -> Result<impl IntoResponse, AppError> { | |
| 339 | - csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 340 | - | |
| 341 | - if !TRUST_MODES.contains(&form.fediverse_trust_mode.as_str()) { | |
| 342 | - return Err(AppError::Validation(format!( | |
| 343 | - "invalid trust_mode: {}", | |
| 344 | - form.fediverse_trust_mode | |
| 345 | - ))); | |
| 346 | - } | |
| 347 | - if !INTEROP_SCOPES.contains(&form.fediverse_interop_scope.as_str()) { | |
| 348 | - return Err(AppError::Validation(format!( | |
| 349 | - "invalid interop_scope: {}", | |
| 350 | - form.fediverse_interop_scope | |
| 351 | - ))); | |
| 352 | - } | |
| 353 | - | |
| 354 | - let bool_str = |opt: &Option<String>| { | |
| 355 | - if opt.is_some() { | |
| 356 | - "true".to_string() | |
| 357 | - } else { | |
| 358 | - "false".to_string() | |
| 359 | - } | |
| 360 | - }; | |
| 361 | - | |
| 362 | - let updated_by = Some(claims.sub); | |
| 363 | - | |
| 364 | - AppSettingsService::set_setting( | |
| 365 | - &db, | |
| 366 | - "fediverse_enabled".to_string(), | |
| 367 | - bool_str(&form.fediverse_enabled), | |
| 368 | - updated_by, | |
| 369 | - ) | |
| 370 | - .await?; | |
| 371 | - AppSettingsService::set_setting( | |
| 372 | - &db, | |
| 373 | - "fediverse_trust_mode".to_string(), | |
| 374 | - form.fediverse_trust_mode, | |
| 375 | - updated_by, | |
| 376 | - ) | |
| 377 | - .await?; | |
| 378 | - AppSettingsService::set_setting( | |
| 379 | - &db, | |
| 380 | - "fediverse_interop_scope".to_string(), | |
| 381 | - form.fediverse_interop_scope, | |
| 382 | - updated_by, | |
| 383 | - ) | |
| 384 | - .await?; | |
| 385 | - AppSettingsService::set_setting( | |
| 386 | - &db, | |
| 387 | - "fediverse_authorized_fetch".to_string(), | |
| 388 | - bool_str(&form.fediverse_authorized_fetch), | |
| 389 | - updated_by, | |
| 390 | - ) | |
| 391 | - .await?; | |
| 392 | - | |
| 393 | - Ok(Redirect::to("/admin/fediverse/settings?saved=1")) | |
| 394 | -} | |
| 395 | - | |
| 396 | -// ───────────────────────────────────────────────────────────────────── | |
| 397 | -// /admin/fediverse/trust_list (RFC 0021 Slice A iter. 2) | |
| 398 | -// ───────────────────────────────────────────────────────────────────── | |
| 399 | - | |
| 400 | -/// One row rendered into the trust-list table. Whether it represents a | |
| 401 | -/// trusted or a blocked domain is conveyed by the surrounding template | |
| 402 | -/// state (`is_blocklist`). | |
| 403 | -#[derive(Debug, Clone)] | |
| 404 | -pub struct TrustEntryCtx { | |
| 405 | - /// Normalised domain. | |
| 406 | - pub domain: String, | |
| 407 | - /// Optional admin note (allowlist) or blocking reason (blocklist). | |
| 408 | - pub note_or_reason: String, | |
| 409 | - /// `"true"` when [`Self::note_or_reason`] is non-empty (Askama lacks | |
| 410 | - /// a clean `Option` ergonomic for templates). | |
| 411 | - pub has_note_or_reason: bool, | |
| 412 | - /// Severity (blocklist only): `suspend` / `silence` / `reject_auth`. | |
| 413 | - /// Empty string for trusted entries. | |
| 414 | - pub severity: String, | |
| 415 | - /// `created_at` formatted as `YYYY-MM-DD`. | |
| 416 | - pub created_at: String, | |
| 417 | -} | |
| 418 | - | |
| 419 | -/// Form payload for `POST /admin/fediverse/trust_list/add`. | |
| 420 | -#[derive(Debug, Deserialize)] | |
| 421 | -pub struct AddTrustEntryForm { | |
| 422 | - /// CSRF token from the hidden form field. | |
| 423 | - pub csrf_token: String, | |
| 424 | - /// Domain submitted by the admin (free-form, normalised on insert). | |
| 425 | - pub domain: String, | |
| 426 | - /// Optional note (allowlist) or reason (blocklist). | |
| 427 | - #[serde(default)] | |
| 428 | - pub note_or_reason: String, | |
| 429 | - /// Blocklist severity: `suspend` / `silence` / `reject_auth`. Ignored | |
| 430 | - /// when the active mode is `allowlist`. | |
| 431 | - #[serde(default)] | |
| 432 | - pub severity: String, | |
| 433 | -} | |
| 434 | - | |
| 435 | -/// Form payload for `POST /admin/fediverse/trust_list/delete`. | |
| 436 | -#[derive(Debug, Deserialize)] | |
| 437 | -pub struct DeleteTrustEntryForm { | |
| 438 | - /// CSRF token from the hidden form field. | |
| 439 | - pub csrf_token: String, | |
| 440 | - /// Domain to remove from the active list. | |
| 441 | - pub domain: String, | |
| 442 | -} | |
| 443 | - | |
| 444 | -/// Askama template for `GET /admin/fediverse/trust_list`. The fields | |
| 445 | -/// below carry pre-translated UI strings whose intent is conveyed by | |
| 446 | -/// their names; verbose per-field rustdoc would be noise on a 38-key | |
| 447 | -/// table. | |
| 448 | -#[derive(Template)] | |
| 449 | -#[template(path = "admin/fediverse/trust_list.html")] | |
| 450 | -#[allow(dead_code, missing_docs)] | |
| 451 | -pub struct FediverseTrustListTemplate { | |
| 452 | - /// Browser tab + page title. | |
| 453 | - pub page_title: String, | |
| 454 | - /// CSRF token for the add + delete forms. | |
| 455 | - pub csrf_token: String, | |
| 456 | - /// App display fields shared with `settings.html`. | |
| 457 | - pub app_name: String, | |
| 458 | - pub app_version: String, | |
| 459 | - pub app_domain: String, | |
| 460 | - pub app_description: String, | |
| 461 | - pub username: String, | |
| 462 | - pub email: String, | |
| 463 | - pub initials: String, | |
| 464 | - pub avatar_color: String, | |
| 465 | - pub roles_text: String, | |
| 466 | - pub has_pending_password_change: bool, | |
| 467 | - pub nav_items: Vec<NavItem>, | |
| 468 | - pub user_menu_profile: String, | |
| 469 | - pub user_menu_logout: String, | |
| 470 | - /// Toast banner shown when `?saved=1`. | |
| 471 | - pub saved_flash: bool, | |
| 472 | - /// `true` when the active mode is `open_with_blocklist` — drives | |
| 473 | - /// table headers and form fields (severity selector vs note). | |
| 474 | - pub is_blocklist: bool, | |
| 475 | - /// Pre-loaded entries. | |
| 476 | - pub entries: Vec<TrustEntryCtx>, | |
| 477 | - /// Whether [`Self::entries`] is empty (Askama lacks `is_empty()` | |
| 478 | - /// in stable expressions across versions). | |
| 479 | - pub has_entries: bool, | |
| 480 | - | |
| 481 | - // i18n | |
| 482 | - pub heading: String, | |
| 483 | - pub subtitle: String, | |
| 484 | - pub mode_indicator: String, | |
| 485 | - pub add_form_legend: String, | |
| 486 | - pub domain_label: String, | |
| 487 | - pub domain_placeholder: String, | |
| 488 | - pub note_label: String, | |
| 489 | - pub reason_label: String, | |
| 490 | - pub severity_label: String, | |
| 491 | - pub severity_suspend: String, | |
| 492 | - pub severity_silence: String, | |
| 493 | - pub severity_reject_auth: String, | |
| 494 | - pub add_button: String, | |
| 495 | - pub table_domain: String, | |
| 496 | - pub table_note_or_reason: String, | |
| 497 | - pub table_severity: String, | |
| 498 | - pub table_created_at: String, | |
| 499 | - pub table_actions: String, | |
| 500 | - pub delete_button: String, | |
| 501 | - pub delete_confirm_title: String, | |
| 502 | - pub delete_confirm_body: String, | |
| 503 | - pub modal_cancel: String, | |
| 504 | - pub modal_confirm: String, | |
| 505 | - pub empty_title: String, | |
| 506 | - pub empty_body: String, | |
| 507 | - pub saved_toast: String, | |
| 508 | - pub breadcrumb_home: String, | |
| 509 | - pub breadcrumb_admin: String, | |
| 510 | - pub breadcrumb_self: String, | |
| 511 | -} | |
| 512 | - | |
| 513 | -rustwarden_core::impl_template_response!(FediverseTrustListTemplate); | |
| 514 | - | |
| 515 | -/// Allowed `severity` form values (RFC 0004). | |
| 516 | -const SEVERITIES: &[&str] = &["suspend", "silence", "reject_auth"]; | |
| 517 | - | |
| 518 | -/// Render `GET /admin/fediverse/trust_list`. | |
| 519 | -/// | |
| 520 | -/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` so the | |
| 521 | -/// page is invisible while the feature is off. The active list (allow | |
| 522 | -/// vs block) is selected from `app_settings.fediverse_trust_mode`. | |
| 523 | -pub async fn get_trust_list( | |
| 524 | - Extension(claims): Extension<Claims>, | |
| 525 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 526 | - Extension(locale): Extension<Locale>, | |
| 527 | - State(db): State<DatabaseConnection>, | |
| 528 | - session: Session, | |
| 529 | - axum::extract::Query(query): axum::extract::Query<SavedQuery>, | |
| 530 | -) -> Result<axum::response::Response, AppError> { | |
| 531 | - let trust = FediverseTrustService::new(db.clone()); | |
| 532 | - | |
| 533 | - if !trust.is_enabled().await? { | |
| 534 | - return Ok(( | |
| 535 | - axum::http::StatusCode::NOT_FOUND, | |
| 536 | - "Federation disabled", | |
| 537 | - ) | |
| 538 | - .into_response()); | |
| 539 | - } | |
| 540 | - | |
| 541 | - let locale_str = locale.as_str(); | |
| 542 | - let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 543 | - let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 544 | - | |
| 545 | - let mode = trust.trust_mode().await?; | |
| 546 | - let is_blocklist = matches!(mode, TrustMode::OpenWithBlocklist); | |
| 547 | - | |
| 548 | - let entries: Vec<TrustEntryCtx> = if is_blocklist { | |
| 549 | - trust | |
| 550 | - .list_blocked_domains() | |
| 551 | - .await? | |
| 552 | - .into_iter() | |
| 553 | - .map(|m| TrustEntryCtx { | |
| 554 | - domain: m.domain, | |
| 555 | - has_note_or_reason: m.reason.as_deref().is_some_and(|s| !s.is_empty()), | |
| 556 | - note_or_reason: m.reason.unwrap_or_default(), | |
| 557 | - severity: m.severity, | |
| 558 | - created_at: m.created_at.format("%Y-%m-%d").to_string(), | |
| 559 | - }) | |
| 560 | - .collect() | |
| 561 | - } else { | |
| 562 | - trust | |
| 563 | - .list_trusted_domains() | |
| 564 | - .await? | |
| 565 | - .into_iter() | |
| 566 | - .map(|m| TrustEntryCtx { | |
| 567 | - domain: m.domain, | |
| 568 | - has_note_or_reason: m.note.as_deref().is_some_and(|s| !s.is_empty()), | |
| 569 | - note_or_reason: m.note.unwrap_or_default(), | |
| 570 | - severity: String::new(), | |
| 571 | - created_at: m.created_at.format("%Y-%m-%d").to_string(), | |
| 572 | - }) | |
| 573 | - .collect() | |
| 574 | - }; | |
| 575 | - let has_entries = !entries.is_empty(); | |
| 576 | - | |
| 577 | - let t = |k: &str| { | |
| 578 | - let i18n = i18n_service.clone(); | |
| 579 | - let key = k.to_string(); | |
| 580 | - let loc = locale_str.to_string(); | |
| 581 | - async move { i18n.translate(&key, &loc).await } | |
| 582 | - }; | |
| 583 | - | |
| 584 | - let mode_indicator = if is_blocklist { | |
| 585 | - t("fediverse.trust.mode_blocklist").await | |
| 586 | - } else { | |
| 587 | - t("fediverse.trust.mode_allowlist").await | |
| 588 | - }; | |
| 589 | - | |
| 590 | - let template = FediverseTrustListTemplate { | |
| 591 | - page_title: t("fediverse.trust.title").await, | |
| 592 | - csrf_token, | |
| 593 | - app_name: base_context.app_name, | |
| 594 | - app_version: base_context.app_version, | |
| 595 | - app_domain: base_context.app_domain, | |
| 596 | - app_description: base_context.app_description, | |
| 597 | - username: base_context.username, | |
| 598 | - email: base_context.email, | |
| 599 | - initials: base_context.initials, | |
| 600 | - avatar_color: base_context.avatar_color, | |
| 601 | - roles_text: base_context.roles.join(", "), | |
| 602 | - has_pending_password_change: false, | |
| 603 | - nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 604 | - .await, | |
| 605 | - user_menu_profile: t("user_menu.profile").await, | |
| 606 | - user_menu_logout: t("user_menu.logout").await, | |
| 607 | - saved_flash: query.saved.unwrap_or(0) == 1, | |
| 608 | - is_blocklist, | |
| 609 | - entries, | |
| 610 | - has_entries, | |
| 611 | - heading: t("fediverse.trust.heading").await, | |
| 612 | - subtitle: t("fediverse.trust.subtitle").await, | |
| 613 | - mode_indicator, | |
| 614 | - add_form_legend: t("fediverse.trust.add_legend").await, | |
| 615 | - domain_label: t("fediverse.trust.domain_label").await, | |
| 616 | - domain_placeholder: t("fediverse.trust.domain_placeholder").await, | |
| 617 | - note_label: t("fediverse.trust.note_label").await, | |
| 618 | - reason_label: t("fediverse.trust.reason_label").await, | |
| 619 | - severity_label: t("fediverse.trust.severity_label").await, | |
| 620 | - severity_suspend: t("fediverse.trust.severity_suspend").await, | |
| 621 | - severity_silence: t("fediverse.trust.severity_silence").await, | |
| 622 | - severity_reject_auth: t("fediverse.trust.severity_reject_auth").await, | |
| 623 | - add_button: t("fediverse.trust.add_button").await, | |
| 624 | - table_domain: t("fediverse.trust.table_domain").await, | |
| 625 | - table_note_or_reason: t("fediverse.trust.table_note_or_reason").await, | |
| 626 | - table_severity: t("fediverse.trust.table_severity").await, | |
| 627 | - table_created_at: t("fediverse.trust.table_created_at").await, | |
| 628 | - table_actions: t("fediverse.trust.table_actions").await, | |
| 629 | - delete_button: t("fediverse.trust.delete_button").await, | |
| 630 | - delete_confirm_title: t("fediverse.trust.delete_confirm_title").await, | |
| 631 | - delete_confirm_body: t("fediverse.trust.delete_confirm_body").await, | |
| 632 | - modal_cancel: t("fediverse.trust.modal_cancel").await, | |
| 633 | - modal_confirm: t("fediverse.trust.modal_confirm").await, | |
| 634 | - empty_title: t("fediverse.trust.empty_title").await, | |
| 635 | - empty_body: t("fediverse.trust.empty_body").await, | |
| 636 | - saved_toast: t("fediverse.trust.saved_toast").await, | |
| 637 | - breadcrumb_home: t("breadcrumb.home").await, | |
| 638 | - breadcrumb_admin: t("breadcrumb.admin").await, | |
| 639 | - breadcrumb_self: t("fediverse.trust.breadcrumb").await, | |
| 640 | - }; | |
| 641 | - | |
| 642 | - Ok(template.into_response()) | |
| 643 | -} | |
| 644 | - | |
| 645 | -/// Apply `POST /admin/fediverse/trust_list/add`. Adds an entry to the | |
| 646 | -/// allowlist or the blocklist depending on the current `trust_mode`. | |
| 647 | -pub async fn add_trust_entry( | |
| 648 | - Extension(claims): Extension<Claims>, | |
| 649 | - State(db): State<DatabaseConnection>, | |
| 650 | - session: Session, | |
| 651 | - Form(form): Form<AddTrustEntryForm>, | |
| 652 | -) -> Result<impl IntoResponse, AppError> { | |
| 653 | - csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 654 | - | |
| 655 | - let domain = form.domain.trim(); | |
| 656 | - if domain.is_empty() { | |
| 657 | - return Err(AppError::Validation("domain must not be empty".into())); | |
| 658 | - } | |
| 659 | - | |
| 660 | - let trust = FediverseTrustService::new(db.clone()); | |
| 661 | - if !trust.is_enabled().await? { | |
| 662 | - return Ok(axum::http::StatusCode::NOT_FOUND.into_response()); | |
| 663 | - } | |
| 664 | - let mode = trust.trust_mode().await?; | |
| 665 | - let added_by = Some(claims.sub); | |
| 666 | - let note_opt = if form.note_or_reason.trim().is_empty() { | |
| 667 | - None | |
| 668 | - } else { | |
| 669 | - Some(form.note_or_reason.trim()) | |
| 670 | - }; | |
| 671 | - | |
| 672 | - match mode { | |
| 673 | - TrustMode::Allowlist => { | |
| 674 | - trust | |
| 675 | - .add_trusted_domain(domain, note_opt, added_by) | |
| 676 | - .await?; | |
| 677 | - } | |
| 678 | - TrustMode::OpenWithBlocklist => { | |
| 679 | - if !SEVERITIES.contains(&form.severity.as_str()) { | |
| 680 | - return Err(AppError::Validation(format!( | |
| 681 | - "invalid severity: {}", | |
| 682 | - form.severity | |
| 683 | - ))); | |
| 684 | - } | |
| 685 | - let severity = BlockSeverity::parse(&form.severity); | |
| 686 | - trust | |
| 687 | - .add_blocked_domain(domain, severity, note_opt, added_by) | |
| 688 | - .await?; | |
| 689 | - // F9 (iter 4 / S4) — auto-kill: blocking a domain must | |
| 690 | - // immediately disable any active `remote_identities` for | |
| 691 | - // that domain. Without this, the admin has to perform two | |
| 692 | - // separate actions for a single intent (« cut this hostile | |
| 693 | - // instance »). The middleware F10 check then rejects all | |
| 694 | - // future protected requests carrying a JWT bound to the | |
| 695 | - // disabled identity. | |
| 696 | - use rustwarden_fediverse::services::fediverse_trust_service::normalize_domain; | |
| 697 | - use sea_orm::sea_query::Expr; | |
| 698 | - let now = chrono::Utc::now(); | |
| 699 | - let normalized = normalize_domain(domain); | |
| 700 | - remote_identity::Entity::update_many() | |
| 701 | - .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) | |
| 702 | - .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) | |
| 703 | - .filter(remote_identity::Column::Domain.eq(normalized)) | |
| 704 | - .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 705 | - .exec(&db) | |
| 706 | - .await?; | |
| 707 | - } | |
| 708 | - } | |
| 709 | - | |
| 710 | - Ok(Redirect::to("/admin/fediverse/trust_list?saved=1").into_response()) | |
| 711 | -} | |
| 712 | - | |
| 713 | -/// Apply `POST /admin/fediverse/trust_list/delete`. Removes an entry by | |
| 714 | -/// canonical domain (URL-safe form input) from the active list. | |
| 715 | -pub async fn delete_trust_entry( | |
| 716 | - State(db): State<DatabaseConnection>, | |
| 717 | - session: Session, | |
| 718 | - Form(form): Form<DeleteTrustEntryForm>, | |
| 719 | -) -> Result<impl IntoResponse, AppError> { | |
| 720 | - csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 721 | - | |
| 722 | - let trust = FediverseTrustService::new(db.clone()); | |
| 723 | - if !trust.is_enabled().await? { | |
| 724 | - return Ok(axum::http::StatusCode::NOT_FOUND.into_response()); | |
| 725 | - } | |
| 726 | - let mode = trust.trust_mode().await?; | |
| 727 | - | |
| 728 | - match mode { | |
| 729 | - TrustMode::Allowlist => { | |
| 730 | - trust.remove_trusted_domain(&form.domain).await?; | |
| 731 | - } | |
| 732 | - TrustMode::OpenWithBlocklist => { | |
| 733 | - trust.remove_blocked_domain(&form.domain).await?; | |
| 734 | - } | |
| 735 | - } | |
| 736 | - | |
| 737 | - Ok(Redirect::to("/admin/fediverse/trust_list?saved=1").into_response()) | |
| 738 | -} | |
| 739 | - | |
| 740 | -// ───────────────────────────────────────────────────────────────────── | |
| 741 | -// /admin/fediverse/queue (RFC 0024 Slice C iter. 1) | |
| 742 | -// ───────────────────────────────────────────────────────────────────── | |
| 743 | - | |
| 744 | -/// One row rendered into the outbound-queue table. | |
| 745 | -#[derive(Debug, Clone)] | |
| 746 | -pub struct OutboundQueueRowCtx { | |
| 747 | - /// Activity ID (truncated for display). | |
| 748 | - pub activity_id: String, | |
| 749 | - /// Target inbox URL. | |
| 750 | - pub target_inbox: String, | |
| 751 | - /// Target domain extracted by `FediverseOutboundService::enqueue`. | |
| 752 | - pub target_domain: String, | |
| 753 | - /// `pending` / `delivered` / `failed` / `dead`. | |
| 754 | - pub status: String, | |
| 755 | - /// Number of attempts already performed. | |
| 756 | - pub attempts: i32, | |
| 757 | - /// Maximum attempts before terminal `dead`. | |
| 758 | - pub max_attempts: i32, | |
| 759 | - /// Last delivery error truncated. | |
| 760 | - pub last_error: String, | |
| 761 | - /// `true` when [`Self::last_error`] is non-empty. | |
| 762 | - pub has_last_error: bool, | |
| 763 | - /// `next_retry_at` formatted RFC3339, empty when `None`. | |
| 764 | - pub next_retry_at: String, | |
| 765 | - /// Insertion timestamp formatted `YYYY-MM-DD HH:MM:SS`. | |
| 766 | - pub created_at: String, | |
| 767 | -} | |
| 768 | - | |
| 769 | -/// Askama template for `GET /admin/fediverse/queue`. | |
| 770 | -#[derive(Template)] | |
| 771 | -#[template(path = "admin/fediverse/queue.html")] | |
| 772 | -#[allow(dead_code, missing_docs)] | |
| 773 | -pub struct FediverseQueueTemplate { | |
| 774 | - pub page_title: String, | |
| 775 | - pub csrf_token: String, | |
| 776 | - pub app_name: String, | |
| 777 | - pub app_version: String, | |
| 778 | - pub app_domain: String, | |
| 779 | - pub app_description: String, | |
| 780 | - pub username: String, | |
| 781 | - pub email: String, | |
| 782 | - pub initials: String, | |
| 783 | - pub avatar_color: String, | |
| 784 | - pub roles_text: String, | |
| 785 | - pub has_pending_password_change: bool, | |
| 786 | - pub nav_items: Vec<NavItem>, | |
| 787 | - pub user_menu_profile: String, | |
| 788 | - pub user_menu_logout: String, | |
| 789 | - | |
| 790 | - /// Pre-loaded last 100 rows ordered by `created_at desc`. | |
| 791 | - pub rows: Vec<OutboundQueueRowCtx>, | |
| 792 | - /// `true` when [`Self::rows`] is empty. | |
| 793 | - pub has_rows: bool, | |
| 794 | - /// Per-status counts (sums all rows, not just the 100 visible). | |
| 795 | - pub count_pending: u64, | |
| 796 | - pub count_delivered: u64, | |
| 797 | - pub count_failed: u64, | |
| 798 | - pub count_dead: u64, | |
| 799 | - | |
| 800 | - pub heading: String, | |
| 801 | - pub subtitle: String, | |
| 802 | - pub stats_pending: String, | |
| 803 | - pub stats_delivered: String, | |
| 804 | - pub stats_failed: String, | |
| 805 | - pub stats_dead: String, | |
| 806 | - pub table_activity: String, | |
| 807 | - pub table_target: String, | |
| 808 | - pub table_status: String, | |
| 809 | - pub table_attempts: String, | |
| 810 | - pub table_last_error: String, | |
| 811 | - pub table_next_retry: String, | |
| 812 | - pub table_created_at: String, | |
| 813 | - pub empty_title: String, | |
| 814 | - pub empty_body: String, | |
| 815 | - pub breadcrumb_home: String, | |
| 816 | - pub breadcrumb_admin: String, | |
| 817 | - pub breadcrumb_self: String, | |
| 818 | -} | |
| 819 | - | |
| 820 | -rustwarden_core::impl_template_response!(FediverseQueueTemplate); | |
| 821 | - | |
| 822 | -/// Truncate a string to `max` chars, appending `…` when cut. | |
| 823 | -fn truncate(s: &str, max: usize) -> String { | |
| 824 | - if s.chars().count() <= max { | |
| 825 | - s.to_string() | |
| 826 | - } else { | |
| 827 | - let head: String = s.chars().take(max.saturating_sub(1)).collect(); | |
| 828 | - format!("{head}…") | |
| 829 | - } | |
| 830 | -} | |
| 831 | - | |
| 832 | -/// Render `GET /admin/fediverse/queue` — operator dashboard for the | |
| 833 | -/// outbound delivery queue (RFC 0011 + RFC 0019). Shows the last 100 | |
| 834 | -/// rows + per-status counts so an admin can spot a backlog at a glance. | |
| 835 | -/// | |
| 836 | -/// Returns `404` when `fediverse_enabled = false` (Plan UI §3 P9). | |
| 837 | -pub async fn get_queue( | |
| 838 | - Extension(claims): Extension<Claims>, | |
| 839 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 840 | - Extension(locale): Extension<Locale>, | |
| 841 | - State(db): State<DatabaseConnection>, | |
| 842 | - session: Session, | |
| 843 | -) -> Result<axum::response::Response, AppError> { | |
| 844 | - let trust = FediverseTrustService::new(db.clone()); | |
| 845 | - if !trust.is_enabled().await? { | |
| 846 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 847 | - } | |
| 848 | - | |
| 849 | - let locale_str = locale.as_str(); | |
| 850 | - let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 851 | - let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 852 | - | |
| 853 | - let count_pending = fediverse_outbound_queue::Entity::find() | |
| 854 | - .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Pending.as_str())) | |
| 855 | - .count(&db) | |
| 856 | - .await | |
| 857 | - .unwrap_or(0); | |
| 858 | - let count_delivered = fediverse_outbound_queue::Entity::find() | |
| 859 | - .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Delivered.as_str())) | |
| 860 | - .count(&db) | |
| 861 | - .await | |
| 862 | - .unwrap_or(0); | |
| 863 | - let count_failed = fediverse_outbound_queue::Entity::find() | |
| 864 | - .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Failed.as_str())) | |
| 865 | - .count(&db) | |
| 866 | - .await | |
| 867 | - .unwrap_or(0); | |
| 868 | - let count_dead = fediverse_outbound_queue::Entity::find() | |
| 869 | - .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Dead.as_str())) | |
| 870 | - .count(&db) | |
| 871 | - .await | |
| 872 | - .unwrap_or(0); | |
| 873 | - | |
| 874 | - let rows_raw = fediverse_outbound_queue::Entity::find() | |
| 875 | - .order_by_desc(fediverse_outbound_queue::Column::CreatedAt) | |
| 876 | - .limit(100) | |
| 877 | - .all(&db) | |
| 878 | - .await?; | |
| 879 | - | |
| 880 | - let rows: Vec<OutboundQueueRowCtx> = rows_raw | |
| 881 | - .into_iter() | |
| 882 | - .map(|m| OutboundQueueRowCtx { | |
| 883 | - activity_id: truncate(&m.activity_id, 60), | |
| 884 | - target_inbox: truncate(&m.target_inbox, 60), | |
| 885 | - target_domain: m.target_domain, | |
| 886 | - status: m.status, | |
| 887 | - attempts: m.attempts, | |
| 888 | - max_attempts: m.max_attempts, | |
| 889 | - has_last_error: m.last_error.as_deref().is_some_and(|s| !s.is_empty()), | |
| 890 | - last_error: m | |
| 891 | - .last_error | |
| 892 | - .map(|e| truncate(&e, 80)) | |
| 893 | - .unwrap_or_default(), | |
| 894 | - next_retry_at: m | |
| 895 | - .next_retry_at | |
| 896 | - .map(|t| t.to_rfc3339()) | |
| 897 | - .unwrap_or_default(), | |
| 898 | - created_at: m.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), | |
| 899 | - }) | |
| 900 | - .collect(); | |
| 901 | - let has_rows = !rows.is_empty(); | |
| 902 | - | |
| 903 | - let t = |k: &str| { | |
| 904 | - let i18n = i18n_service.clone(); | |
| 905 | - let key = k.to_string(); | |
| 906 | - let loc = locale_str.to_string(); | |
| 907 | - async move { i18n.translate(&key, &loc).await } | |
| 908 | - }; | |
| 909 | - | |
| 910 | - let template = FediverseQueueTemplate { | |
| 911 | - page_title: t("fediverse.queue.title").await, | |
| 912 | - csrf_token, | |
| 913 | - app_name: base_context.app_name, | |
| 914 | - app_version: base_context.app_version, | |
| 915 | - app_domain: base_context.app_domain, | |
| 916 | - app_description: base_context.app_description, | |
| 917 | - username: base_context.username, | |
| 918 | - email: base_context.email, | |
| 919 | - initials: base_context.initials, | |
| 920 | - avatar_color: base_context.avatar_color, | |
| 921 | - roles_text: base_context.roles.join(", "), | |
| 922 | - has_pending_password_change: false, | |
| 923 | - nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 924 | - .await, | |
| 925 | - user_menu_profile: t("user_menu.profile").await, | |
| 926 | - user_menu_logout: t("user_menu.logout").await, | |
| 927 | - | |
| 928 | - rows, | |
| 929 | - has_rows, | |
| 930 | - count_pending, | |
| 931 | - count_delivered, | |
| 932 | - count_failed, | |
| 933 | - count_dead, | |
| 934 | - | |
| 935 | - heading: t("fediverse.queue.heading").await, | |
| 936 | - subtitle: t("fediverse.queue.subtitle").await, | |
| 937 | - stats_pending: t("fediverse.queue.stats_pending").await, | |
| 938 | - stats_delivered: t("fediverse.queue.stats_delivered").await, | |
| 939 | - stats_failed: t("fediverse.queue.stats_failed").await, | |
| 940 | - stats_dead: t("fediverse.queue.stats_dead").await, | |
| 941 | - table_activity: t("fediverse.queue.table_activity").await, | |
| 942 | - table_target: t("fediverse.queue.table_target").await, | |
| 943 | - table_status: t("fediverse.queue.table_status").await, | |
| 944 | - table_attempts: t("fediverse.queue.table_attempts").await, | |
| 945 | - table_last_error: t("fediverse.queue.table_last_error").await, | |
| 946 | - table_next_retry: t("fediverse.queue.table_next_retry").await, | |
| 947 | - table_created_at: t("fediverse.queue.table_created_at").await, | |
| 948 | - empty_title: t("fediverse.queue.empty_title").await, | |
| 949 | - empty_body: t("fediverse.queue.empty_body").await, | |
| 950 | - breadcrumb_home: t("breadcrumb.home").await, | |
| 951 | - breadcrumb_admin: t("breadcrumb.admin").await, | |
| 952 | - breadcrumb_self: t("fediverse.queue.breadcrumb").await, | |
| 953 | - }; | |
| 954 | - | |
| 955 | - Ok(template.into_response()) | |
| 956 | -} | |
| 957 | - | |
| 958 | -// ───────────────────────────────────────────────────────────────────── | |
| 959 | -// /admin/fediverse/sessions_remote (RFC 0024 Slice C iter. 2) | |
| 960 | -// ───────────────────────────────────────────────────────────────────── | |
| 961 | - | |
| 962 | -/// One row rendered into the sessions-remote table. | |
| 963 | -#[derive(Debug, Clone)] | |
| 964 | -pub struct RemoteSessionRowCtx { | |
| 965 | - /// PK of the remote_identities row. | |
| 966 | - pub id: String, | |
| 967 | - /// Canonical AP id (`https://b.tld/users/alice`), truncated. | |
| 968 | - pub ap_id: String, | |
| 969 | - /// `@user@domain` recomposed for human display. | |
| 970 | - pub handle: String, | |
| 971 | - /// Display name advertised by the actor (or empty). | |
| 972 | - pub display_name: String, | |
| 973 | - /// Whether [`Self::display_name`] is non-empty. | |
| 974 | - pub has_display_name: bool, | |
| 975 | - /// Domain of the federated instance. | |
| 976 | - pub domain: String, | |
| 977 | - /// `last_fetched_at` formatted `YYYY-MM-DD HH:MM`. | |
| 978 | - pub last_seen: String, | |
| 979 | -} | |
| 980 | - | |
| 981 | -/// Form payload for `POST /admin/fediverse/sessions_remote/revoke`. | |
| 982 | -#[derive(Debug, Deserialize)] | |
| 983 | -pub struct RevokeRemoteForm { | |
| 984 | - /// CSRF token from the hidden form field. | |
| 985 | - pub csrf_token: String, | |
| 986 | - /// PK of the remote_identities row to disable. | |
| 987 | - pub id: String, | |
| 988 | -} | |
| 989 | - | |
| 990 | -/// Form payload for `POST /admin/fediverse/sessions_remote/kill_domain`. | |
| 991 | -#[derive(Debug, Deserialize)] | |
| 992 | -pub struct KillDomainForm { | |
| 993 | - /// CSRF token from the hidden form field. | |
| 994 | - pub csrf_token: String, | |
| 995 | - /// Domain whose remote_identities rows should be disabled in bulk. | |
| 996 | - pub domain: String, | |
| 997 | -} | |
| 998 | - | |
| 999 | -/// Askama template for `GET /admin/fediverse/sessions_remote`. | |
| 1000 | -#[derive(Template)] | |
| 1001 | -#[template(path = "admin/fediverse/sessions_remote.html")] | |
| 1002 | -#[allow(dead_code, missing_docs)] | |
| 1003 | -pub struct FediverseSessionsRemoteTemplate { | |
| 1004 | - pub page_title: String, | |
| 1005 | - pub csrf_token: String, | |
| 1006 | - pub app_name: String, | |
| 1007 | - pub app_version: String, | |
| 1008 | - pub app_domain: String, | |
| 1009 | - pub app_description: String, | |
| 1010 | - pub username: String, | |
| 1011 | - pub email: String, | |
| 1012 | - pub initials: String, | |
| 1013 | - pub avatar_color: String, | |
| 1014 | - pub roles_text: String, | |
| 1015 | - pub has_pending_password_change: bool, | |
| 1016 | - pub nav_items: Vec<NavItem>, | |
| 1017 | - pub user_menu_profile: String, | |
| 1018 | - pub user_menu_logout: String, | |
| 1019 | - | |
| 1020 | - pub saved_flash: bool, | |
| 1021 | - pub rows: Vec<RemoteSessionRowCtx>, | |
| 1022 | - pub has_rows: bool, | |
| 1023 | - /// Distinct domains present in [`Self::rows`] for the kill-switch | |
| 1024 | - /// `<select>`. | |
| 1025 | - pub distinct_domains: Vec<String>, | |
| 1026 | - | |
| 1027 | - pub heading: String, | |
| 1028 | - pub subtitle: String, | |
| 1029 | - pub kill_switch_legend: String, | |
| 1030 | - pub kill_switch_label: String, | |
| 1031 | - pub kill_switch_button: String, | |
| 1032 | - pub kill_switch_confirm_title: String, | |
| 1033 | - pub kill_switch_confirm_body: String, | |
| 1034 | - pub table_handle: String, | |
| 1035 | - pub table_display_name: String, | |
| 1036 | - pub table_domain: String, | |
| 1037 | - pub table_last_seen: String, | |
| 1038 | - pub table_actions: String, | |
| 1039 | - pub revoke_button: String, | |
| 1040 | - pub revoke_confirm_title: String, | |
| 1041 | - pub revoke_confirm_body: String, | |
| 1042 | - pub modal_cancel: String, | |
| 1043 | - pub modal_confirm: String, | |
| 1044 | - pub empty_title: String, | |
| 1045 | - pub empty_body: String, | |
| 1046 | - pub saved_toast: String, | |
| 1047 | - pub breadcrumb_home: String, | |
| 1048 | - pub breadcrumb_admin: String, | |
| 1049 | - pub breadcrumb_self: String, | |
| 1050 | -} | |
| 1051 | - | |
| 1052 | -rustwarden_core::impl_template_response!(FediverseSessionsRemoteTemplate); | |
| 1053 | - | |
| 1054 | -/// Render `GET /admin/fediverse/sessions_remote` — list active federated | |
| 1055 | -/// identities (rows with `disabled_at IS NULL`). | |
| 1056 | -pub async fn get_sessions_remote( | |
| 1057 | - Extension(claims): Extension<Claims>, | |
| 1058 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 1059 | - Extension(locale): Extension<Locale>, | |
| 1060 | - State(db): State<DatabaseConnection>, | |
| 1061 | - session: Session, | |
| 1062 | - axum::extract::Query(query): axum::extract::Query<SavedQuery>, | |
| 1063 | -) -> Result<axum::response::Response, AppError> { | |
| 1064 | - let trust = FediverseTrustService::new(db.clone()); | |
| 1065 | - if !trust.is_enabled().await? { | |
| 1066 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1067 | - } | |
| 1068 | - | |
| 1069 | - let locale_str = locale.as_str(); | |
| 1070 | - let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 1071 | - let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 1072 | - | |
| 1073 | - let active_rows = remote_identity::Entity::find() | |
| 1074 | - .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 1075 | - .order_by_desc(remote_identity::Column::LastFetchedAt) | |
| 1076 | - .limit(200) | |
| 1077 | - .all(&db) | |
| 1078 | - .await?; | |
| 1079 | - | |
| 1080 | - let rows: Vec<RemoteSessionRowCtx> = active_rows | |
| 1081 | - .iter() | |
| 1082 | - .map(|m| RemoteSessionRowCtx { | |
| 1083 | - id: m.id.to_string(), | |
| 1084 | - ap_id: truncate(&m.ap_id, 60), | |
| 1085 | - handle: format!("@{}@{}", m.username, m.domain), | |
| 1086 | - has_display_name: m | |
| 1087 | - .display_name | |
| 1088 | - .as_deref() | |
| 1089 | - .is_some_and(|s| !s.is_empty()), | |
| 1090 | - display_name: m.display_name.clone().unwrap_or_default(), | |
| 1091 | - domain: m.domain.clone(), | |
| 1092 | - last_seen: m.last_fetched_at.format("%Y-%m-%d %H:%M").to_string(), | |
| 1093 | - }) | |
| 1094 | - .collect(); | |
| 1095 | - let has_rows = !rows.is_empty(); | |
| 1096 | - | |
| 1097 | - let mut distinct_domains: Vec<String> = active_rows | |
| 1098 | - .iter() | |
| 1099 | - .map(|m| m.domain.clone()) | |
| 1100 | - .collect::<std::collections::BTreeSet<_>>() | |
| 1101 | - .into_iter() | |
| 1102 | - .collect(); | |
| 1103 | - distinct_domains.sort(); | |
| 1104 | - | |
| 1105 | - let t = |k: &str| { | |
| 1106 | - let i18n = i18n_service.clone(); | |
| 1107 | - let key = k.to_string(); | |
| 1108 | - let loc = locale_str.to_string(); | |
| 1109 | - async move { i18n.translate(&key, &loc).await } | |
| 1110 | - }; | |
| 1111 | - | |
| 1112 | - let template = FediverseSessionsRemoteTemplate { | |
| 1113 | - page_title: t("fediverse.sessions_remote.title").await, | |
| 1114 | - csrf_token, | |
| 1115 | - app_name: base_context.app_name, | |
| 1116 | - app_version: base_context.app_version, | |
| 1117 | - app_domain: base_context.app_domain, | |
| 1118 | - app_description: base_context.app_description, | |
| 1119 | - username: base_context.username, | |
| 1120 | - email: base_context.email, | |
| 1121 | - initials: base_context.initials, | |
| 1122 | - avatar_color: base_context.avatar_color, | |
| 1123 | - roles_text: base_context.roles.join(", "), | |
| 1124 | - has_pending_password_change: false, | |
| 1125 | - nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 1126 | - .await, | |
| 1127 | - user_menu_profile: t("user_menu.profile").await, | |
| 1128 | - user_menu_logout: t("user_menu.logout").await, | |
| 1129 | - | |
| 1130 | - saved_flash: query.saved.unwrap_or(0) == 1, | |
| 1131 | - rows, | |
| 1132 | - has_rows, | |
| 1133 | - distinct_domains, | |
| 1134 | - | |
| 1135 | - heading: t("fediverse.sessions_remote.heading").await, | |
| 1136 | - subtitle: t("fediverse.sessions_remote.subtitle").await, | |
| 1137 | - kill_switch_legend: t("fediverse.sessions_remote.kill_switch_legend").await, | |
| 1138 | - kill_switch_label: t("fediverse.sessions_remote.kill_switch_label").await, | |
| 1139 | - kill_switch_button: t("fediverse.sessions_remote.kill_switch_button").await, | |
| 1140 | - kill_switch_confirm_title: t("fediverse.sessions_remote.kill_switch_confirm_title").await, | |
| 1141 | - kill_switch_confirm_body: t("fediverse.sessions_remote.kill_switch_confirm_body").await, | |
| 1142 | - table_handle: t("fediverse.sessions_remote.table_handle").await, | |
| 1143 | - table_display_name: t("fediverse.sessions_remote.table_display_name").await, | |
| 1144 | - table_domain: t("fediverse.sessions_remote.table_domain").await, | |
| 1145 | - table_last_seen: t("fediverse.sessions_remote.table_last_seen").await, | |
| 1146 | - table_actions: t("fediverse.sessions_remote.table_actions").await, | |
| 1147 | - revoke_button: t("fediverse.sessions_remote.revoke_button").await, | |
| 1148 | - revoke_confirm_title: t("fediverse.sessions_remote.revoke_confirm_title").await, | |
| 1149 | - revoke_confirm_body: t("fediverse.sessions_remote.revoke_confirm_body").await, | |
| 1150 | - modal_cancel: t("fediverse.sessions_remote.modal_cancel").await, | |
| 1151 | - modal_confirm: t("fediverse.sessions_remote.modal_confirm").await, | |
| 1152 | - empty_title: t("fediverse.sessions_remote.empty_title").await, | |
| 1153 | - empty_body: t("fediverse.sessions_remote.empty_body").await, | |
| 1154 | - saved_toast: t("fediverse.sessions_remote.saved_toast").await, | |
| 1155 | - breadcrumb_home: t("breadcrumb.home").await, | |
| 1156 | - breadcrumb_admin: t("breadcrumb.admin").await, | |
| 1157 | - breadcrumb_self: t("fediverse.sessions_remote.breadcrumb").await, | |
| 1158 | - }; | |
| 1159 | - | |
| 1160 | - Ok(template.into_response()) | |
| 1161 | -} | |
| 1162 | - | |
| 1163 | -/// `POST /admin/fediverse/sessions_remote/revoke` — set `disabled_at` | |
| 1164 | -/// on a single remote_identities row. | |
| 1165 | -pub async fn revoke_remote_session( | |
| 1166 | - State(db): State<DatabaseConnection>, | |
| 1167 | - session: Session, | |
| 1168 | - Form(form): Form<RevokeRemoteForm>, | |
| 1169 | -) -> Result<axum::response::Response, AppError> { | |
| 1170 | - csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 1171 | - | |
| 1172 | - let trust = FediverseTrustService::new(db.clone()); | |
| 1173 | - if !trust.is_enabled().await? { | |
| 1174 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1175 | - } | |
| 1176 | - | |
| 1177 | - let id = uuid::Uuid::parse_str(&form.id) | |
| 1178 | - .map_err(|_| AppError::Validation("invalid remote identity id".into()))?; | |
| 1179 | - let now = chrono::Utc::now(); | |
| 1180 | - | |
| 1181 | - use sea_orm::sea_query::Expr; | |
| 1182 | - remote_identity::Entity::update_many() | |
| 1183 | - .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) | |
| 1184 | - .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) | |
| 1185 | - .filter(remote_identity::Column::Id.eq(id)) | |
| 1186 | - .exec(&db) | |
| 1187 | - .await?; | |
| 1188 | - | |
| 1189 | - Ok(Redirect::to("/admin/fediverse/sessions_remote?saved=1").into_response()) | |
| 1190 | -} | |
| 1191 | - | |
| 1192 | -/// `POST /admin/fediverse/sessions_remote/kill_domain` — bulk disable | |
| 1193 | -/// every remote_identities row from a given domain. Used as a panic | |
| 1194 | -/// button when an entire federated instance is compromised. | |
| 1195 | -pub async fn kill_domain( | |
| 1196 | - State(db): State<DatabaseConnection>, | |
| 1197 | - session: Session, | |
| 1198 | - Form(form): Form<KillDomainForm>, | |
| 1199 | -) -> Result<axum::response::Response, AppError> { | |
| 1200 | - csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 1201 | - | |
| 1202 | - let trust = FediverseTrustService::new(db.clone()); | |
| 1203 | - if !trust.is_enabled().await? { | |
| 1204 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1205 | - } | |
| 1206 | - | |
| 1207 | - let domain = form.domain.trim().to_ascii_lowercase(); | |
| 1208 | - if domain.is_empty() { | |
| 1209 | - return Err(AppError::Validation("domain must not be empty".into())); | |
| 1210 | - } | |
| 1211 | - let now = chrono::Utc::now(); | |
| 1212 | - | |
| 1213 | - use sea_orm::sea_query::Expr; | |
| 1214 | - remote_identity::Entity::update_many() | |
| 1215 | - .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) | |
| 1216 | - .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) | |
| 1217 | - .filter(remote_identity::Column::Domain.eq(domain)) | |
| 1218 | - .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 1219 | - .exec(&db) | |
| 1220 | - .await?; | |
| 1221 | - | |
| 1222 | - Ok(Redirect::to("/admin/fediverse/sessions_remote?saved=1").into_response()) | |
| 1223 | -} | |
| 1224 | - | |
| 1225 | -// ───────────────────────────────────────────────────────────────────── | |
| 1226 | -// /admin/fediverse/nodeinfo (RFC 0024 Slice C iter. 3) | |
| 1227 | -// ───────────────────────────────────────────────────────────────────── | |
| 1228 | - | |
| 1229 | -/// Askama template for `GET /admin/fediverse/nodeinfo`. | |
| 1230 | -#[derive(Template)] | |
| 1231 | -#[template(path = "admin/fediverse/nodeinfo.html")] | |
| 1232 | -#[allow(dead_code, missing_docs)] | |
| 1233 | -pub struct FediverseNodeInfoTemplate { | |
| 1234 | - pub page_title: String, | |
| 1235 | - pub csrf_token: String, | |
| 1236 | - pub app_name: String, | |
| 1237 | - pub app_version: String, | |
| 1238 | - pub app_domain: String, | |
| 1239 | - pub app_description: String, | |
| 1240 | - pub username: String, | |
| 1241 | - pub email: String, | |
| 1242 | - pub initials: String, | |
| 1243 | - pub avatar_color: String, | |
| 1244 | - pub roles_text: String, | |
| 1245 | - pub has_pending_password_change: bool, | |
| 1246 | - pub nav_items: Vec<NavItem>, | |
| 1247 | - pub user_menu_profile: String, | |
| 1248 | - pub user_menu_logout: String, | |
| 1249 | - | |
| 1250 | - // Software | |
| 1251 | - pub software_name: String, | |
| 1252 | - pub software_version: String, | |
| 1253 | - pub fediverse_domain: String, | |
| 1254 | - // Federation runtime config | |
| 1255 | - pub fediverse_enabled_label: String, | |
| 1256 | - pub trust_mode: String, | |
| 1257 | - pub interop_scope: String, | |
| 1258 | - pub authorized_fetch: bool, | |
| 1259 | - // User counts | |
| 1260 | - pub users_total: u64, | |
| 1261 | - pub users_active_month: u64, | |
| 1262 | - pub users_active_halfyear: u64, | |
| 1263 | - pub remote_identities_active: u64, | |
| 1264 | - pub remote_distinct_domains: u64, | |
| 1265 | - | |
| 1266 | - // i18n | |
| 1267 | - pub heading: String, | |
| 1268 | - pub subtitle: String, | |
| 1269 | - pub card_software: String, | |
| 1270 | - pub card_runtime: String, | |
| 1271 | - pub card_users: String, | |
| 1272 | - pub card_remotes: String, | |
| 1273 | - pub label_name: String, | |
| 1274 | - pub label_version: String, | |
| 1275 | - pub label_domain: String, | |
| 1276 | - pub label_enabled: String, | |
| 1277 | - pub label_trust_mode: String, | |
| 1278 | - pub label_interop_scope: String, | |
| 1279 | - pub label_authorized_fetch: String, | |
| 1280 | - pub label_users_total: String, | |
| 1281 | - pub label_users_active_month: String, | |
| 1282 | - pub label_users_active_halfyear: String, | |
| 1283 | - pub label_remote_identities_active: String, | |
| 1284 | - pub label_remote_distinct_domains: String, | |
| 1285 | - pub yes_label: String, | |
| 1286 | - pub no_label: String, | |
| 1287 | - pub breadcrumb_home: String, | |
| 1288 | - pub breadcrumb_admin: String, | |
| 1289 | - pub breadcrumb_self: String, | |
| 1290 | -} | |
| 1291 | - | |
| 1292 | -rustwarden_core::impl_template_response!(FediverseNodeInfoTemplate); | |
| 1293 | - | |
| 1294 | -/// Render `GET /admin/fediverse/nodeinfo` — humanised stats panel. | |
| 1295 | -/// | |
| 1296 | -/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false`. | |
| 1297 | -pub async fn get_nodeinfo( | |
| 1298 | - Extension(claims): Extension<Claims>, | |
| 1299 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 1300 | - Extension(locale): Extension<Locale>, | |
| 1301 | - State(db): State<DatabaseConnection>, | |
| 1302 | - session: Session, | |
| 1303 | -) -> Result<axum::response::Response, AppError> { | |
| 1304 | - let trust = FediverseTrustService::new(db.clone()); | |
| 1305 | - if !trust.is_enabled().await? { | |
| 1306 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1307 | - } | |
| 1308 | - | |
| 1309 | - let locale_str = locale.as_str(); | |
| 1310 | - let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 1311 | - let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 1312 | - | |
| 1313 | - // Federation runtime config (read straight from app_settings). | |
| 1314 | - let trust_mode = match trust.trust_mode().await? { | |
| 1315 | - TrustMode::Allowlist => "allowlist".to_string(), | |
| 1316 | - TrustMode::OpenWithBlocklist => "open_with_blocklist".to_string(), | |
| 1317 | - }; | |
| 1318 | - let interop_scope = AppSettingsService::get_setting(&db, "fediverse_interop_scope") | |
| 1319 | - .await? | |
| 1320 | - .map(|s| s.value) | |
| 1321 | - .unwrap_or_else(|| "rustwarden_only".to_string()); | |
| 1322 | - let authorized_fetch = | |
| 1323 | - AppSettingsService::get_bool(&db, "fediverse_authorized_fetch", false) | |
| 1324 | - .await?; | |
| 1325 | - | |
| 1326 | - // User counts (local users). | |
| 1327 | - let users_total = user_model::Entity::find().count(&db).await.unwrap_or(0); | |
| 1328 | - let now = chrono::Utc::now(); | |
| 1329 | - let users_active_month = user_model::Entity::find() | |
| 1330 | - .filter(user_model::Column::LastLoginAt.gte(now - chrono::Duration::days(30))) | |
| 1331 | - .count(&db) | |
| 1332 | - .await | |
| 1333 | - .unwrap_or(0); | |
| 1334 | - let users_active_halfyear = user_model::Entity::find() | |
| 1335 | - .filter(user_model::Column::LastLoginAt.gte(now - chrono::Duration::days(180))) | |
| 1336 | - .count(&db) | |
| 1337 | - .await | |
| 1338 | - .unwrap_or(0); | |
| 1339 | - | |
| 1340 | - // Remote identities (active = disabled_at IS NULL). | |
| 1341 | - let remote_identities_active = remote_identity::Entity::find() | |
| 1342 | - .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 1343 | - .count(&db) | |
| 1344 | - .await | |
| 1345 | - .unwrap_or(0); | |
| 1346 | - | |
| 1347 | - let remote_rows = remote_identity::Entity::find() | |
| 1348 | - .filter(remote_identity::Column::DisabledAt.is_null()) | |
| 1349 | - .all(&db) | |
| 1350 | - .await | |
| 1351 | - .unwrap_or_default(); | |
| 1352 | - let remote_distinct_domains = remote_rows | |
| 1353 | - .iter() | |
| 1354 | - .map(|m| m.domain.clone()) | |
| 1355 | - .collect::<std::collections::BTreeSet<_>>() | |
| 1356 | - .len() as u64; | |
| 1357 | - | |
| 1358 | - let fediverse_domain = std::env::var("FEDIVERSE_DOMAIN").unwrap_or_else(|_| "—".to_string()); | |
| 1359 | - | |
| 1360 | - let t = |k: &str| { | |
| 1361 | - let i18n = i18n_service.clone(); | |
| 1362 | - let key = k.to_string(); | |
| 1363 | - let loc = locale_str.to_string(); | |
| 1364 | - async move { i18n.translate(&key, &loc).await } | |
| 1365 | - }; | |
| 1366 | - | |
| 1367 | - let template = FediverseNodeInfoTemplate { | |
| 1368 | - page_title: t("fediverse_nodeinfo.title").await, | |
| 1369 | - csrf_token, | |
| 1370 | - app_name: base_context.app_name, | |
| 1371 | - app_version: base_context.app_version, | |
| 1372 | - app_domain: base_context.app_domain, | |
| 1373 | - app_description: base_context.app_description, | |
| 1374 | - username: base_context.username, | |
| 1375 | - email: base_context.email, | |
| 1376 | - initials: base_context.initials, | |
| 1377 | - avatar_color: base_context.avatar_color, | |
| 1378 | - roles_text: base_context.roles.join(", "), | |
| 1379 | - has_pending_password_change: false, | |
| 1380 | - nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 1381 | - .await, | |
| 1382 | - user_menu_profile: t("user_menu.profile").await, | |
| 1383 | - user_menu_logout: t("user_menu.logout").await, | |
| 1384 | - | |
| 1385 | - software_name: "rustwarden".to_string(), | |
| 1386 | - software_version: env!("CARGO_PKG_VERSION").to_string(), | |
| 1387 | - fediverse_domain, | |
| 1388 | - fediverse_enabled_label: t("common.yes").await, | |
| 1389 | - trust_mode, | |
| 1390 | - interop_scope, | |
| 1391 | - authorized_fetch, | |
| 1392 | - users_total, | |
| 1393 | - users_active_month, | |
| 1394 | - users_active_halfyear, | |
| 1395 | - remote_identities_active, | |
| 1396 | - remote_distinct_domains, | |
| 1397 | - | |
| 1398 | - heading: t("fediverse_nodeinfo.heading").await, | |
| 1399 | - subtitle: t("fediverse_nodeinfo.subtitle").await, | |
| 1400 | - card_software: t("fediverse_nodeinfo.card_software").await, | |
| 1401 | - card_runtime: t("fediverse_nodeinfo.card_runtime").await, | |
| 1402 | - card_users: t("fediverse_nodeinfo.card_users").await, | |
| 1403 | - card_remotes: t("fediverse_nodeinfo.card_remotes").await, | |
| 1404 | - label_name: t("fediverse_nodeinfo.label_name").await, | |
| 1405 | - label_version: t("fediverse_nodeinfo.label_version").await, | |
| 1406 | - label_domain: t("fediverse_nodeinfo.label_domain").await, | |
| 1407 | - label_enabled: t("fediverse_nodeinfo.label_enabled").await, | |
| 1408 | - label_trust_mode: t("fediverse_nodeinfo.label_trust_mode").await, | |
| 1409 | - label_interop_scope: t("fediverse_nodeinfo.label_interop_scope").await, | |
| 1410 | - label_authorized_fetch: t("fediverse_nodeinfo.label_authorized_fetch").await, | |
| 1411 | - label_users_total: t("fediverse_nodeinfo.label_users_total").await, | |
| 1412 | - label_users_active_month: t("fediverse_nodeinfo.label_users_active_month").await, | |
| 1413 | - label_users_active_halfyear: t("fediverse_nodeinfo.label_users_active_halfyear").await, | |
| 1414 | - label_remote_identities_active: t("fediverse_nodeinfo.label_remote_identities_active") | |
| 1415 | - .await, | |
| 1416 | - label_remote_distinct_domains: t("fediverse_nodeinfo.label_remote_distinct_domains").await, | |
| 1417 | - yes_label: t("common.yes").await, | |
| 1418 | - no_label: t("common.no").await, | |
| 1419 | - breadcrumb_home: t("breadcrumb.home").await, | |
| 1420 | - breadcrumb_admin: t("breadcrumb.admin").await, | |
| 1421 | - breadcrumb_self: t("fediverse_nodeinfo.breadcrumb").await, | |
| 1422 | - }; | |
| 1423 | - | |
| 1424 | - Ok(template.into_response()) | |
| 1425 | -} | |
| 1426 | - | |
| 1427 | -// ───────────────────────────────────────────────────────────────────── | |
| 1428 | -// /admin/fediverse/invites/{team_id} (RFC 0024 Slice C iter. 4) | |
| 1429 | -// ───────────────────────────────────────────────────────────────────── | |
| 1430 | - | |
| 1431 | -/// One row rendered into the team invites table. | |
| 1432 | -#[derive(Debug, Clone)] | |
| 1433 | -pub struct TeamInviteRowCtx { | |
| 1434 | - /// PK of the invite row. | |
| 1435 | - pub id: String, | |
| 1436 | - /// `@user@domain` handle. | |
| 1437 | - pub invited_handle: String, | |
| 1438 | - /// Wire form: `owner` / `admin` / `member`. | |
| 1439 | - pub role: String, | |
| 1440 | - /// Wire form: `pending` / `accepted` / `rejected` / `revoked` / `expired`. | |
| 1441 | - pub status: String, | |
| 1442 | - /// `created_at` formatted `YYYY-MM-DD HH:MM`. | |
| 1443 | - pub created_at: String, | |
| 1444 | -} | |
| 1445 | - | |
| 1446 | -/// Form payload for `POST /admin/fediverse/invites/{team_id}/add`. | |
| 1447 | -#[derive(Debug, Deserialize)] | |
| 1448 | -pub struct CreateTeamInviteForm { | |
| 1449 | - /// CSRF token from the hidden form field. | |
| 1450 | - pub csrf_token: String, | |
| 1451 | - /// `@user@domain` handle to invite. | |
| 1452 | - pub handle: String, | |
| 1453 | - /// `owner` / `admin` / `member`. | |
| 1454 | - pub role: String, | |
| 1455 | -} | |
| 1456 | - | |
| 1457 | -/// Form payload for `POST /admin/fediverse/invites/{team_id}/revoke`. | |
| 1458 | -#[derive(Debug, Deserialize)] | |
| 1459 | -pub struct RevokeInviteForm { | |
| 1460 | - /// CSRF token from the hidden form field. | |
| 1461 | - pub csrf_token: String, | |
| 1462 | - /// PK of the invite row to revoke. | |
| 1463 | - pub id: String, | |
| 1464 | -} | |
| 1465 | - | |
| 1466 | -/// Askama template for `GET /admin/fediverse/invites/{team_id}`. | |
| 1467 | -#[derive(Template)] | |
| 1468 | -#[template(path = "admin/fediverse/team_invites.html")] | |
| 1469 | -#[allow(dead_code, missing_docs)] | |
| 1470 | -pub struct FediverseTeamInvitesTemplate { | |
| 1471 | - pub page_title: String, | |
| 1472 | - pub csrf_token: String, | |
| 1473 | - pub app_name: String, | |
| 1474 | - pub app_version: String, | |
| 1475 | - pub app_domain: String, | |
| 1476 | - pub app_description: String, | |
| 1477 | - pub username: String, | |
| 1478 | - pub email: String, | |
| 1479 | - pub initials: String, | |
| 1480 | - pub avatar_color: String, | |
| 1481 | - pub roles_text: String, | |
| 1482 | - pub has_pending_password_change: bool, | |
| 1483 | - pub nav_items: Vec<NavItem>, | |
| 1484 | - pub user_menu_profile: String, | |
| 1485 | - pub user_menu_logout: String, | |
| 1486 | - | |
| 1487 | - pub saved_flash: bool, | |
| 1488 | - /// Team name for the page heading. | |
| 1489 | - pub team_name: String, | |
| 1490 | - /// Team UUID (for form actions). | |
| 1491 | - pub team_id: String, | |
| 1492 | - pub rows: Vec<TeamInviteRowCtx>, | |
| 1493 | - pub has_rows: bool, | |
| 1494 | - | |
| 1495 | - pub heading: String, | |
| 1496 | - pub subtitle: String, | |
| 1497 | - pub add_form_legend: String, | |
| 1498 | - pub handle_label: String, | |
| 1499 | - pub handle_placeholder: String, | |
| 1500 | - pub role_label: String, | |
| 1501 | - pub role_member: String, | |
| 1502 | - pub role_admin: String, | |
| 1503 | - pub role_owner: String, | |
| 1504 | - pub add_button: String, | |
| 1505 | - pub table_handle: String, | |
| 1506 | - pub table_role: String, | |
| 1507 | - pub table_status: String, | |
| 1508 | - pub table_created_at: String, | |
| 1509 | - pub table_actions: String, | |
| 1510 | - pub revoke_button: String, | |
| 1511 | - pub revoke_confirm_title: String, | |
| 1512 | - pub revoke_confirm_body: String, | |
| 1513 | - pub modal_cancel: String, | |
| 1514 | - pub modal_confirm: String, | |
| 1515 | - pub empty_title: String, | |
| 1516 | - pub empty_body: String, | |
| 1517 | - pub saved_toast: String, | |
| 1518 | - pub breadcrumb_home: String, | |
| 1519 | - pub breadcrumb_admin: String, | |
| 1520 | - pub breadcrumb_self: String, | |
| 1521 | -} | |
| 1522 | - | |
| 1523 | -rustwarden_core::impl_template_response!(FediverseTeamInvitesTemplate); | |
| 1524 | - | |
| 1525 | -/// Default TTL for team invitations (RFC 0009 §6). | |
| 1526 | -const INVITE_TTL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60); | |
| 1527 | - | |
| 1528 | -/// `GET /admin/fediverse/invites/{team_id}` — list pending invites. | |
| 1529 | -pub async fn get_team_invites( | |
| 1530 | - Extension(claims): Extension<Claims>, | |
| 1531 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 1532 | - Extension(locale): Extension<Locale>, | |
| 1533 | - State(db): State<DatabaseConnection>, | |
| 1534 | - session: Session, | |
| 1535 | - axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>, | |
| 1536 | - axum::extract::Query(query): axum::extract::Query<SavedQuery>, | |
| 1537 | -) -> Result<axum::response::Response, AppError> { | |
| 1538 | - let trust = FediverseTrustService::new(db.clone()); | |
| 1539 | - if !trust.is_enabled().await? { | |
| 1540 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1541 | - } | |
| 1542 | - | |
| 1543 | - // Verify team exists; otherwise 404. | |
| 1544 | - let Some(team_row) = team::Entity::find_by_id(team_id).one(&db).await? else { | |
| 1545 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Team not found").into_response()); | |
| 1546 | - }; | |
| 1547 | - | |
| 1548 | - let locale_str = locale.as_str(); | |
| 1549 | - let base_context = build_base_context_with_claims(&session, &db, &claims).await?; | |
| 1550 | - let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 1551 | - | |
| 1552 | - // Show all statuses for this team (not just pending) so the admin | |
| 1553 | - // sees the historical decisions. Order by created_at desc. | |
| 1554 | - let invite_rows = fediverse_pending_invite::Entity::find() | |
| 1555 | - .filter(fediverse_pending_invite::Column::TeamId.eq(team_id)) | |
| 1556 | - .order_by_desc(fediverse_pending_invite::Column::CreatedAt) | |
| 1557 | - .limit(100) | |
| 1558 | - .all(&db) | |
| 1559 | - .await?; | |
| 1560 | - | |
| 1561 | - let rows: Vec<TeamInviteRowCtx> = invite_rows | |
| 1562 | - .into_iter() | |
| 1563 | - .map(|m| TeamInviteRowCtx { | |
| 1564 | - id: m.id.to_string(), | |
| 1565 | - invited_handle: m.invited_handle, | |
| 1566 | - role: m.role, | |
| 1567 | - status: m.status, | |
| 1568 | - created_at: m.created_at.format("%Y-%m-%d %H:%M").to_string(), | |
| 1569 | - }) | |
| 1570 | - .collect(); | |
| 1571 | - let has_rows = !rows.is_empty(); | |
| 1572 | - | |
| 1573 | - let t = |k: &str| { | |
| 1574 | - let i18n = i18n_service.clone(); | |
| 1575 | - let key = k.to_string(); | |
| 1576 | - let loc = locale_str.to_string(); | |
| 1577 | - async move { i18n.translate(&key, &loc).await } | |
| 1578 | - }; | |
| 1579 | - | |
| 1580 | - let template = FediverseTeamInvitesTemplate { | |
| 1581 | - page_title: t("fediverse_invites.title").await, | |
| 1582 | - csrf_token, | |
| 1583 | - app_name: base_context.app_name, | |
| 1584 | - app_version: base_context.app_version, | |
| 1585 | - app_domain: base_context.app_domain, | |
| 1586 | - app_description: base_context.app_description, | |
| 1587 | - username: base_context.username, | |
| 1588 | - email: base_context.email, | |
| 1589 | - initials: base_context.initials, | |
| 1590 | - avatar_color: base_context.avatar_color, | |
| 1591 | - roles_text: base_context.roles.join(", "), | |
| 1592 | - has_pending_password_change: false, | |
| 1593 | - nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) | |
| 1594 | - .await, | |
| 1595 | - user_menu_profile: t("user_menu.profile").await, | |
| 1596 | - user_menu_logout: t("user_menu.logout").await, | |
| 1597 | - | |
| 1598 | - saved_flash: query.saved.unwrap_or(0) == 1, | |
| 1599 | - team_name: team_row.name, | |
| 1600 | - team_id: team_id.to_string(), | |
| 1601 | - rows, | |
| 1602 | - has_rows, | |
| 1603 | - | |
| 1604 | - heading: t("fediverse_invites.heading").await, | |
| 1605 | - subtitle: t("fediverse_invites.subtitle").await, | |
| 1606 | - add_form_legend: t("fediverse_invites.add_legend").await, | |
| 1607 | - handle_label: t("fediverse_invites.handle_label").await, | |
| 1608 | - handle_placeholder: t("fediverse_invites.handle_placeholder").await, | |
| 1609 | - role_label: t("fediverse_invites.role_label").await, | |
| 1610 | - role_member: t("fediverse_invites.role_member").await, | |
| 1611 | - role_admin: t("fediverse_invites.role_admin").await, | |
| 1612 | - role_owner: t("fediverse_invites.role_owner").await, | |
| 1613 | - add_button: t("fediverse_invites.add_button").await, | |
| 1614 | - table_handle: t("fediverse_invites.table_handle").await, | |
| 1615 | - table_role: t("fediverse_invites.table_role").await, | |
| 1616 | - table_status: t("fediverse_invites.table_status").await, | |
| 1617 | - table_created_at: t("fediverse_invites.table_created_at").await, | |
| 1618 | - table_actions: t("fediverse_invites.table_actions").await, | |
| 1619 | - revoke_button: t("fediverse_invites.revoke_button").await, | |
| 1620 | - revoke_confirm_title: t("fediverse_invites.revoke_confirm_title").await, | |
| 1621 | - revoke_confirm_body: t("fediverse_invites.revoke_confirm_body").await, | |
| 1622 | - modal_cancel: t("fediverse_invites.modal_cancel").await, | |
| 1623 | - modal_confirm: t("fediverse_invites.modal_confirm").await, | |
| 1624 | - empty_title: t("fediverse_invites.empty_title").await, | |
| 1625 | - empty_body: t("fediverse_invites.empty_body").await, | |
| 1626 | - saved_toast: t("fediverse_invites.saved_toast").await, | |
| 1627 | - breadcrumb_home: t("breadcrumb.home").await, | |
| 1628 | - breadcrumb_admin: t("breadcrumb.admin").await, | |
| 1629 | - breadcrumb_self: t("fediverse_invites.breadcrumb").await, | |
| 1630 | - }; | |
| 1631 | - | |
| 1632 | - Ok(template.into_response()) | |
| 1633 | -} | |
| 1634 | - | |
| 1635 | -/// `POST /admin/fediverse/invites/{team_id}/add` — create a pending invite. | |
| 1636 | -pub async fn create_team_invite( | |
| 1637 | - Extension(claims): Extension<Claims>, | |
| 1638 | - State(db): State<DatabaseConnection>, | |
| 1639 | - session: Session, | |
| 1640 | - axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>, | |
| 1641 | - Form(form): Form<CreateTeamInviteForm>, | |
| 1642 | -) -> Result<axum::response::Response, AppError> { | |
| 1643 | - csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 1644 | - | |
| 1645 | - let trust = FediverseTrustService::new(db.clone()); | |
| 1646 | - if !trust.is_enabled().await? { | |
| 1647 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1648 | - } | |
| 1649 | - | |
| 1650 | - let handle = form.handle.trim(); | |
| 1651 | - if handle.is_empty() || !handle.contains('@') { | |
| 1652 | - return Err(AppError::Validation( | |
| 1653 | - "handle must be of form @user@domain".into(), | |
| 1654 | - )); | |
| 1655 | - } | |
| 1656 | - let role = TeamRole::parse(&form.role).ok_or_else(|| { | |
| 1657 | - AppError::Validation(format!("invalid role: {}", form.role)) | |
| 1658 | - })?; | |
| 1659 | - | |
| 1660 | - FediverseInviteService::invite_remote(&db, team_id, handle, role, claims.sub, INVITE_TTL) | |
| 1661 | - .await?; | |
| 1662 | - | |
| 1663 | - Ok(Redirect::to(&format!( | |
| 1664 | - "/admin/fediverse/invites/{team_id}?saved=1" | |
| 1665 | - )) | |
| 1666 | - .into_response()) | |
| 1667 | -} | |
| 1668 | - | |
| 1669 | -/// `POST /admin/fediverse/invites/{team_id}/revoke` — revoke an invite. | |
| 1670 | -pub async fn revoke_team_invite( | |
| 1671 | - State(db): State<DatabaseConnection>, | |
| 1672 | - session: Session, | |
| 1673 | - axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>, | |
| 1674 | - Form(form): Form<RevokeInviteForm>, | |
| 1675 | -) -> Result<axum::response::Response, AppError> { | |
| 1676 | - csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 1677 | - | |
| 1678 | - let trust = FediverseTrustService::new(db.clone()); | |
| 1679 | - if !trust.is_enabled().await? { | |
| 1680 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 1681 | - } | |
| 1682 | - | |
| 1683 | - let invite_id = uuid::Uuid::parse_str(&form.id) | |
| 1684 | - .map_err(|_| AppError::Validation("invalid invite id".into()))?; | |
| 1685 | - | |
| 1686 | - // Touch unused-import guard for InviteStatus (kept exported). | |
| 1687 | - let _ = InviteStatus::Pending; | |
| 1688 | - | |
| 1689 | - FediverseInviteService::revoke(&db, invite_id).await?; | |
| 1690 | - | |
| 1691 | - Ok(Redirect::to(&format!( | |
| 1692 | - "/admin/fediverse/invites/{team_id}?saved=1" | |
| 1693 | - )) | |
| 1694 | - .into_response()) | |
| 1695 | -} |
M
crates/rustwarden-ui/src/handlers/admin_handler.rs
+34
-13
@@ -12,7 +12,7 @@ use serde::Deserialize;
| 12 | 12 | use std::sync::Arc; |
| 13 | 13 | use tower_sessions::Session; |
| 14 | 14 | |
| 15 | -use crate::dto::NavigationDTO; | |
| 15 | +use crate::dto::{DashboardCardDTO, NavigationDTO}; | |
| 16 | 16 | use crate::template_helpers::build_base_context_with_claims; |
| 17 | 17 | use axum::extract::Extension; |
| 18 | 18 | use rustwarden_core::auth::jwt::Claims; |
@@ -213,11 +213,25 @@ struct AdminDashboardTemplate {
| 213 | 213 | settings_card_desc: String, |
| 214 | 214 | apps_card_title: String, |
| 215 | 215 | apps_card_desc: String, |
| 216 | - // Federation card (only displayed when the `fediverse` Cargo feature | |
| 217 | - // is compiled into rustwarden-ui). | |
| 218 | - has_fediverse: bool, | |
| 219 | - fediverse_card_title: String, | |
| 220 | - fediverse_card_desc: String, | |
| 216 | + // Extra cards injected by companion crates (e.g. | |
| 217 | + // `rustwarden-fediverse-ui`) via | |
| 218 | + // `RustwardenBuilder::add_dashboard_card`. Empty when no extension | |
| 219 | + // is wired. | |
| 220 | + extra_cards: Vec<LocalizedDashboardCard>, | |
| 221 | +} | |
| 222 | + | |
| 223 | +/// Pre-translated dashboard card consumed by `admin/dashboard.html`. | |
| 224 | +/// | |
| 225 | +/// Built at render time from the [`DashboardCardDTO`] list injected via | |
| 226 | +/// `Extension<Vec<DashboardCardDTO>>`; resolves the i18n keys against | |
| 227 | +/// the active locale so the template can render directly. | |
| 228 | +#[derive(Debug, Clone)] | |
| 229 | +#[allow(dead_code)] | |
| 230 | +struct LocalizedDashboardCard { | |
| 231 | + title: String, | |
| 232 | + desc: String, | |
| 233 | + href: String, | |
| 234 | + icon: String, | |
| 221 | 235 | } |
| 222 | 236 | |
| 223 | 237 | rustwarden_core::impl_template_response!(AdminDashboardTemplate); |
@@ -228,6 +242,7 @@ pub async fn admin_dashboard(
| 228 | 242 | Extension(i18n_service): Extension<Arc<I18nService>>, |
| 229 | 243 | Extension(locale): Extension<Locale>, |
| 230 | 244 | State(db): State<DatabaseConnection>, |
| 245 | + extra_cards: Option<Extension<Vec<DashboardCardDTO>>>, | |
| 231 | 246 | session: Session, |
| 232 | 247 | ) -> Result<impl IntoResponse, AppError> { |
| 233 | 248 | let locale_str = locale.as_str(); |
@@ -366,13 +381,19 @@ pub async fn admin_dashboard(
| 366 | 381 | apps_card_desc: i18n_service |
| 367 | 382 | .translate("admin.dashboard.apps_card_desc", locale_str) |
| 368 | 383 | .await, |
| 369 | - has_fediverse: cfg!(feature = "fediverse"), | |
| 370 | - fediverse_card_title: i18n_service | |
| 371 | - .translate("admin.dashboard.fediverse_card_title", locale_str) | |
| 372 | - .await, | |
| 373 | - fediverse_card_desc: i18n_service | |
| 374 | - .translate("admin.dashboard.fediverse_card_desc", locale_str) | |
| 375 | - .await, | |
| 384 | + extra_cards: { | |
| 385 | + let cards = extra_cards.map(|Extension(c)| c).unwrap_or_default(); | |
| 386 | + let mut out = Vec::with_capacity(cards.len()); | |
| 387 | + for card in cards { | |
| 388 | + out.push(LocalizedDashboardCard { | |
| 389 | + title: i18n_service.translate(&card.title_key, locale_str).await, | |
| 390 | + desc: i18n_service.translate(&card.desc_key, locale_str).await, | |
| 391 | + href: card.href, | |
| 392 | + icon: card.icon, | |
| 393 | + }); | |
| 394 | + } | |
| 395 | + out | |
| 396 | + }, | |
| 376 | 397 | }; |
| 377 | 398 | |
| 378 | 399 | Ok(template) |
D
crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs
+0
-1053
@@ -1,1053 +0,0 @@
| 1 | -//! User-facing handlers for the Fediverse auth flow (RFC 0023, Slice B). | |
| 2 | -//! | |
| 3 | -//! Iter. 1 ships only the error landing page | |
| 4 | -//! (`/login/fediverse/error?reason=…`) — the four other endpoints | |
| 5 | -//! (login form, waiting page, SSE, consent) land in subsequent iters. | |
| 6 | -//! | |
| 7 | -//! All routes here are **public** (no `Claims` extension required) and | |
| 8 | -//! gated by `experimental-fediverse`. The mode-dégradé (Plan UI §3 P9) | |
| 9 | -//! returns `404` when `fediverse_enabled = false`. | |
| 10 | - | |
| 11 | -use std::convert::Infallible; | |
| 12 | -use std::sync::Arc; | |
| 13 | -use std::time::Duration; | |
| 14 | - | |
| 15 | -use askama::Template; | |
| 16 | -use axum::extract::{Extension, Form, Path, Query, State}; | |
| 17 | -use axum::response::sse::{Event, KeepAlive, Sse}; | |
| 18 | -use axum::response::{IntoResponse, Redirect}; | |
| 19 | -use futures::stream::Stream; | |
| 20 | -use uuid::Uuid; | |
| 21 | -use rustwarden_core::middleware::csrf; | |
| 22 | -use sea_orm::DatabaseConnection; | |
| 23 | -use serde::Deserialize; | |
| 24 | -use tower_sessions::Session; | |
| 25 | - | |
| 26 | -use rustwarden_core::error::AppError; | |
| 27 | -use rustwarden_core::i18n::extractor::Locale; | |
| 28 | -use rustwarden_core::i18n::service::I18nService; | |
| 29 | -use rustwarden_core::auth::jwt::Claims; | |
| 30 | -use rustwarden_fediverse::services::fediverse_auth_service::{ | |
| 31 | - build_auth_decision_payload, ChallengeDecision, ChallengeStatus, FediverseAuthService, | |
| 32 | - InitiateRequest, | |
| 33 | -}; | |
| 34 | -use rustwarden_fediverse::services::fediverse_invite_service::FediverseInviteService; | |
| 35 | -use rustwarden_fediverse::models::fediverse_pending_invite; | |
| 36 | -use rustwarden_fediverse::services::fediverse_trust_service::FediverseTrustService; | |
| 37 | -use rustwarden_fediverse::services::fediverse_outbound_service::{ | |
| 38 | - EnqueueRequest, FediverseOutboundService, | |
| 39 | -}; | |
| 40 | -use rustwarden_fediverse::FediverseConfig; | |
| 41 | -use rustwarden_fediverse::models::remote_identity; | |
| 42 | - | |
| 43 | -use rustwarden_fediverse::models::fediverse_auth_challenge; | |
| 44 | -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; | |
| 45 | - | |
| 46 | -/// Default challenge TTL for the AP-native auth flow (RFC 0006 §6). | |
| 47 | -/// Operator override via `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` is honoured | |
| 48 | -/// by the service layer separately; here we only provide a UI default | |
| 49 | -/// to hand into [`FediverseAuthService::initiate`]. | |
| 50 | -const CHALLENGE_TTL: Duration = Duration::from_secs(5 * 60); | |
| 51 | - | |
| 52 | -/// Whitelist of accepted `?reason=` values. Any other input falls back | |
| 53 | -/// to [`ErrorReason::Unknown`] which renders a generic message — keeps | |
| 54 | -/// the URL safe to share without leaking internal status names. | |
| 55 | -#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 56 | -pub enum ErrorReason { | |
| 57 | - /// `Accept{AuthRequest}` not received within the challenge TTL. | |
| 58 | - Timeout, | |
| 59 | - /// IdP explicitly rejected the auth request. | |
| 60 | - Rejected, | |
| 61 | - /// Domain not in the allowlist (or in the blocklist). | |
| 62 | - Untrusted, | |
| 63 | - /// HTTP signature on the IdP response did not verify. | |
| 64 | - InvalidSignature, | |
| 65 | - /// Anything else (renders the generic catch-all message). | |
| 66 | - Unknown, | |
| 67 | -} | |
| 68 | - | |
| 69 | -impl ErrorReason { | |
| 70 | - /// Parse the `?reason=` query value into an enum. Trims, lowercases. | |
| 71 | - pub fn parse(raw: &str) -> Self { | |
| 72 | - match raw.trim().to_ascii_lowercase().as_str() { | |
| 73 | - "timeout" => Self::Timeout, | |
| 74 | - "rejected" => Self::Rejected, | |
| 75 | - "untrusted" => Self::Untrusted, | |
| 76 | - "invalid_signature" | "invalid_sig" => Self::InvalidSignature, | |
| 77 | - _ => Self::Unknown, | |
| 78 | - } | |
| 79 | - } | |
| 80 | - | |
| 81 | - /// i18n key suffix for the heading + body copy. | |
| 82 | - pub fn i18n_key(self) -> &'static str { | |
| 83 | - match self { | |
| 84 | - Self::Timeout => "timeout", | |
| 85 | - Self::Rejected => "rejected", | |
| 86 | - Self::Untrusted => "untrusted", | |
| 87 | - Self::InvalidSignature => "invalid_signature", | |
| 88 | - Self::Unknown => "unknown", | |
| 89 | - } | |
| 90 | - } | |
| 91 | - | |
| 92 | - /// Stable wire form (echoed back into the template for icon styling). | |
| 93 | - pub fn as_str(self) -> &'static str { | |
| 94 | - self.i18n_key() | |
| 95 | - } | |
| 96 | -} | |
| 97 | - | |
| 98 | -/// `?reason=` query parameter for `GET /login/fediverse/error`. | |
| 99 | -#[derive(Debug, Deserialize)] | |
| 100 | -pub struct ErrorQuery { | |
| 101 | - /// One of `timeout`, `rejected`, `untrusted`, `invalid_signature`. | |
| 102 | - /// Anything else renders the generic message. | |
| 103 | - pub reason: Option<String>, | |
| 104 | -} | |
| 105 | - | |
| 106 | -/// Askama template for `GET /login/fediverse/error`. | |
| 107 | -#[derive(Template)] | |
| 108 | -#[template(path = "fediverse/error_unauthorized.html")] | |
| 109 | -#[allow(dead_code)] | |
| 110 | -pub struct FediverseAuthErrorTemplate { | |
| 111 | - /// IETF locale tag (`fr`, `en`, …) for `<html lang>`. | |
| 112 | - pub current_locale: String, | |
| 113 | - /// Browser tab title. | |
| 114 | - pub page_title: String, | |
| 115 | - /// Meta-description copy. | |
| 116 | - pub subtitle: String, | |
| 117 | - /// Wire form of the error reason — used by the template to pick an | |
| 118 | - /// icon (timeout / invalid_signature / generic alert). | |
| 119 | - pub reason: String, | |
| 120 | - /// i18n-translated H1. | |
| 121 | - pub heading: String, | |
| 122 | - /// i18n-translated body paragraph. | |
| 123 | - pub body: String, | |
| 124 | - /// Primary CTA label. | |
| 125 | - pub back_to_login: String, | |
| 126 | -} | |
| 127 | - | |
| 128 | -rustwarden_core::impl_template_response!(FediverseAuthErrorTemplate); | |
| 129 | - | |
| 130 | -/// `GET /login/fediverse/error?reason=…` — landing page after a failed | |
| 131 | -/// auth attempt. | |
| 132 | -/// | |
| 133 | -/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` so | |
| 134 | -/// the page is invisible while the feature is off. | |
| 135 | -pub async fn error( | |
| 136 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 137 | - Extension(locale): Extension<Locale>, | |
| 138 | - State(db): State<DatabaseConnection>, | |
| 139 | - Query(query): Query<ErrorQuery>, | |
| 140 | -) -> Result<axum::response::Response, AppError> { | |
| 141 | - let trust = FediverseTrustService::new(db.clone()); | |
| 142 | - if !trust.is_enabled().await? { | |
| 143 | - return Ok(( | |
| 144 | - axum::http::StatusCode::NOT_FOUND, | |
| 145 | - "Federation disabled", | |
| 146 | - ) | |
| 147 | - .into_response()); | |
| 148 | - } | |
| 149 | - | |
| 150 | - let reason = ErrorReason::parse(query.reason.as_deref().unwrap_or("")); | |
| 151 | - let locale_str = locale.as_str(); | |
| 152 | - let key = reason.i18n_key(); | |
| 153 | - | |
| 154 | - let heading_key = format!("fediverse.auth.error.{key}.title"); | |
| 155 | - let body_key = format!("fediverse.auth.error.{key}.body"); | |
| 156 | - | |
| 157 | - let template = FediverseAuthErrorTemplate { | |
| 158 | - current_locale: locale_str.to_string(), | |
| 159 | - page_title: i18n_service | |
| 160 | - .translate("fediverse.auth.error.page_title", locale_str) | |
| 161 | - .await, | |
| 162 | - subtitle: i18n_service | |
| 163 | - .translate("fediverse.auth.error.subtitle", locale_str) | |
| 164 | - .await, | |
| 165 | - reason: reason.as_str().to_string(), | |
| 166 | - heading: i18n_service.translate(&heading_key, locale_str).await, | |
| 167 | - body: i18n_service.translate(&body_key, locale_str).await, | |
| 168 | - back_to_login: i18n_service | |
| 169 | - .translate("fediverse.auth.error.back_to_login", locale_str) | |
| 170 | - .await, | |
| 171 | - }; | |
| 172 | - | |
| 173 | - Ok(template.into_response()) | |
| 174 | -} | |
| 175 | - | |
| 176 | -// ───────────────────────────────────────────────────────────────────── | |
| 177 | -// /login/fediverse — login form + initiate (RFC 0023 Slice B iter. 2) | |
| 178 | -// ───────────────────────────────────────────────────────────────────── | |
| 179 | - | |
| 180 | -/// Form payload for `POST /login/fediverse`. | |
| 181 | -#[derive(Debug, Deserialize)] | |
| 182 | -pub struct LoginInitiateForm { | |
| 183 | - /// CSRF token from the hidden form field. | |
| 184 | - pub csrf_token: String, | |
| 185 | - /// `@user@domain` handle the user typed. | |
| 186 | - pub handle: String, | |
| 187 | -} | |
| 188 | - | |
| 189 | -/// Askama template for `GET /login/fediverse`. | |
| 190 | -#[derive(Template)] | |
| 191 | -#[template(path = "fediverse/login_form.html")] | |
| 192 | -#[allow(dead_code, missing_docs)] | |
| 193 | -pub struct FediverseLoginFormTemplate { | |
| 194 | - pub current_locale: String, | |
| 195 | - pub page_title: String, | |
| 196 | - pub subtitle: String, | |
| 197 | - pub csrf_token: String, | |
| 198 | - pub heading: String, | |
| 199 | - pub field_label: String, | |
| 200 | - pub field_placeholder: String, | |
| 201 | - pub field_help: String, | |
| 202 | - pub submit_button: String, | |
| 203 | - pub back_to_login: String, | |
| 204 | -} | |
| 205 | - | |
| 206 | -rustwarden_core::impl_template_response!(FediverseLoginFormTemplate); | |
| 207 | - | |
| 208 | -/// `GET /login/fediverse` — render the AP-native login form. | |
| 209 | -/// | |
| 210 | -/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false`. | |
| 211 | -pub async fn login_form( | |
| 212 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 213 | - Extension(locale): Extension<Locale>, | |
| 214 | - State(db): State<DatabaseConnection>, | |
| 215 | - session: Session, | |
| 216 | -) -> Result<axum::response::Response, AppError> { | |
| 217 | - let trust = FediverseTrustService::new(db.clone()); | |
| 218 | - if !trust.is_enabled().await? { | |
| 219 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 220 | - } | |
| 221 | - | |
| 222 | - let locale_str = locale.as_str(); | |
| 223 | - let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 224 | - | |
| 225 | - let template = FediverseLoginFormTemplate { | |
| 226 | - current_locale: locale_str.to_string(), | |
| 227 | - page_title: i18n_service | |
| 228 | - .translate("fediverse.auth.login.page_title", locale_str) | |
| 229 | - .await, | |
| 230 | - subtitle: i18n_service | |
| 231 | - .translate("fediverse.auth.login.subtitle", locale_str) | |
| 232 | - .await, | |
| 233 | - csrf_token, | |
| 234 | - heading: i18n_service | |
| 235 | - .translate("fediverse.auth.login.heading", locale_str) | |
| 236 | - .await, | |
| 237 | - field_label: i18n_service | |
| 238 | - .translate("fediverse.auth.login.field_label", locale_str) | |
| 239 | - .await, | |
| 240 | - field_placeholder: i18n_service | |
| 241 | - .translate("fediverse.auth.login.field_placeholder", locale_str) | |
| 242 | - .await, | |
| 243 | - field_help: i18n_service | |
| 244 | - .translate("fediverse.auth.login.field_help", locale_str) | |
| 245 | - .await, | |
| 246 | - submit_button: i18n_service | |
| 247 | - .translate("fediverse.auth.login.submit", locale_str) | |
| 248 | - .await, | |
| 249 | - back_to_login: i18n_service | |
| 250 | - .translate("fediverse.auth.login.back_to_login", locale_str) | |
| 251 | - .await, | |
| 252 | - }; | |
| 253 | - | |
| 254 | - Ok(template.into_response()) | |
| 255 | -} | |
| 256 | - | |
| 257 | -/// Extract the `domain` part from a `@user@domain` (or `user@domain`) | |
| 258 | -/// handle. Returns `None` on malformed input. Lowercased. | |
| 259 | -pub fn extract_handle_domain(handle: &str) -> Option<String> { | |
| 260 | - let trimmed = handle.trim().trim_start_matches('@'); | |
| 261 | - let (_, domain) = trimmed.split_once('@')?; | |
| 262 | - let domain = domain.trim(); | |
| 263 | - if domain.is_empty() { | |
| 264 | - return None; | |
| 265 | - } | |
| 266 | - Some(domain.to_ascii_lowercase()) | |
| 267 | -} | |
| 268 | - | |
| 269 | -/// `POST /login/fediverse` — start an AP-native login. | |
| 270 | -/// | |
| 271 | -/// Pipeline: | |
| 272 | -/// 1. Validate CSRF. | |
| 273 | -/// 2. Reject if `fediverse_enabled = false` (404). | |
| 274 | -/// 3. Parse handle ⇒ domain ; reject malformed via `error?reason=unknown`. | |
| 275 | -/// 4. Check `FediverseTrustService::is_domain_allowed(domain)` ; | |
| 276 | -/// redirect to `error?reason=untrusted` if denied. | |
| 277 | -/// 5. Call `FediverseAuthService::initiate` ; on `Ok` redirect to | |
| 278 | -/// `/login/fediverse/waiting/{id}` (page delivered iter. 3). | |
| 279 | -/// 6. On any other error, redirect to `error?reason=unknown`. | |
| 280 | -pub async fn login_initiate( | |
| 281 | - State(db): State<DatabaseConnection>, | |
| 282 | - session: Session, | |
| 283 | - Form(form): Form<LoginInitiateForm>, | |
| 284 | -) -> Result<axum::response::Response, AppError> { | |
| 285 | - csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 286 | - | |
| 287 | - let trust = FediverseTrustService::new(db.clone()); | |
| 288 | - if !trust.is_enabled().await? { | |
| 289 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 290 | - } | |
| 291 | - | |
| 292 | - let Some(domain) = extract_handle_domain(&form.handle) else { | |
| 293 | - return Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()); | |
| 294 | - }; | |
| 295 | - | |
| 296 | - if !trust.is_domain_allowed(&domain).await? { | |
| 297 | - return Ok(Redirect::to("/login/fediverse/error?reason=untrusted").into_response()); | |
| 298 | - } | |
| 299 | - | |
| 300 | - let req = InitiateRequest { | |
| 301 | - handle: form.handle.trim().to_string(), | |
| 302 | - redirect_url: None, | |
| 303 | - }; | |
| 304 | - match FediverseAuthService::initiate(&db, req, CHALLENGE_TTL).await { | |
| 305 | - Ok(challenge) => Ok(Redirect::to(&format!( | |
| 306 | - "/login/fediverse/waiting/{}", | |
| 307 | - challenge.id | |
| 308 | - )) | |
| 309 | - .into_response()), | |
| 310 | - Err(AppError::Validation(_)) => { | |
| 311 | - Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()) | |
| 312 | - } | |
| 313 | - Err(err) => { | |
| 314 | - tracing::error!("FediverseAuthService::initiate failed: {err}"); | |
| 315 | - Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()) | |
| 316 | - } | |
| 317 | - } | |
| 318 | -} | |
| 319 | - | |
| 320 | -// ───────────────────────────────────────────────────────────────────── | |
| 321 | -// /login/fediverse/waiting/{id} — page d'attente + SSE (iter. 3) | |
| 322 | -// ───────────────────────────────────────────────────────────────────── | |
| 323 | - | |
| 324 | -/// Askama template for `GET /login/fediverse/waiting/{challenge_id}`. | |
| 325 | -#[derive(Template)] | |
| 326 | -#[template(path = "fediverse/login_waiting.html")] | |
| 327 | -#[allow(dead_code, missing_docs)] | |
| 328 | -pub struct FediverseLoginWaitingTemplate { | |
| 329 | - pub current_locale: String, | |
| 330 | - pub page_title: String, | |
| 331 | - pub subtitle: String, | |
| 332 | - pub heading: String, | |
| 333 | - pub explainer: String, | |
| 334 | - pub cancel_button: String, | |
| 335 | - /// Challenge UUID — embedded in the JS so the EventSource opens the | |
| 336 | - /// correct SSE stream URL. | |
| 337 | - pub challenge_id: String, | |
| 338 | -} | |
| 339 | - | |
| 340 | -rustwarden_core::impl_template_response!(FediverseLoginWaitingTemplate); | |
| 341 | - | |
| 342 | -/// `GET /login/fediverse/waiting/{challenge_id}` — render the waiting | |
| 343 | -/// page that opens an `EventSource` against the SSE endpoint below. | |
| 344 | -pub async fn waiting( | |
| 345 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 346 | - Extension(locale): Extension<Locale>, | |
| 347 | - State(db): State<DatabaseConnection>, | |
| 348 | - Path(challenge_id): Path<Uuid>, | |
| 349 | -) -> Result<axum::response::Response, AppError> { | |
| 350 | - let trust = FediverseTrustService::new(db.clone()); | |
| 351 | - if !trust.is_enabled().await? { | |
| 352 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 353 | - } | |
| 354 | - | |
| 355 | - // Verify the challenge exists; otherwise show the error page rather | |
| 356 | - // than a stale spinner. | |
| 357 | - if FediverseAuthService::status(&db, challenge_id).await.is_err() { | |
| 358 | - return Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response()); | |
| 359 | - } | |
| 360 | - | |
| 361 | - let locale_str = locale.as_str(); | |
| 362 | - let template = FediverseLoginWaitingTemplate { | |
| 363 | - current_locale: locale_str.to_string(), | |
| 364 | - page_title: i18n_service | |
| 365 | - .translate("fediverse.auth.waiting.page_title", locale_str) | |
| 366 | - .await, | |
| 367 | - subtitle: i18n_service | |
| 368 | - .translate("fediverse.auth.waiting.subtitle", locale_str) | |
| 369 | - .await, | |
| 370 | - heading: i18n_service | |
| 371 | - .translate("fediverse.auth.waiting.heading", locale_str) | |
| 372 | - .await, | |
| 373 | - explainer: i18n_service | |
| 374 | - .translate("fediverse.auth.waiting.explainer", locale_str) | |
| 375 | - .await, | |
| 376 | - cancel_button: i18n_service | |
| 377 | - .translate("fediverse.auth.waiting.cancel", locale_str) | |
| 378 | - .await, | |
| 379 | - challenge_id: challenge_id.to_string(), | |
| 380 | - }; | |
| 381 | - | |
| 382 | - Ok(template.into_response()) | |
| 383 | -} | |
| 384 | - | |
| 385 | -/// Polling cadence between two DB reads in [`sse_status`]. Two seconds | |
| 386 | -/// is the master plan §10 default — small enough that the user | |
| 387 | -/// perceives "live", large enough to keep the DB load negligible. | |
| 388 | -const SSE_POLL_INTERVAL: Duration = Duration::from_secs(2); | |
| 389 | - | |
| 390 | -/// Maximum number of polls before the SSE stream gives up and closes. | |
| 391 | -/// `5 min / 2 s = 150` matches the challenge TTL exactly so a never- | |
| 392 | -/// answered challenge does not keep the connection alive forever. | |
| 393 | -const SSE_MAX_POLLS: u32 = 150; | |
| 394 | - | |
| 395 | -/// `GET /login/fediverse/sse/{challenge_id}` — SSE polling endpoint. | |
| 396 | -/// | |
| 397 | -/// Emits one `event: status` per poll with payload | |
| 398 | -/// `{"state":"pending|approved|rejected|expired|consumed"}`. Stops after | |
| 399 | -/// the first terminal status or [`SSE_MAX_POLLS`] iterations, whichever | |
| 400 | -/// comes first. The endpoint is **public** — the challenge UUID is | |
| 401 | -/// itself the unguessable bearer (256 bits of entropy, RFC 0006). | |
| 402 | -pub async fn sse_status( | |
| 403 | - State(db): State<DatabaseConnection>, | |
| 404 | - Path(challenge_id): Path<Uuid>, | |
| 405 | -) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { | |
| 406 | - let stream = async_status_stream(db, challenge_id); | |
| 407 | - Sse::new(stream).keep_alive(KeepAlive::default()) | |
| 408 | -} | |
| 409 | - | |
| 410 | -fn async_status_stream( | |
| 411 | - db: DatabaseConnection, | |
| 412 | - challenge_id: Uuid, | |
| 413 | -) -> impl Stream<Item = Result<Event, Infallible>> { | |
| 414 | - use futures::stream; | |
| 415 | - | |
| 416 | - stream::unfold( | |
| 417 | - StreamState { | |
| 418 | - db, | |
| 419 | - challenge_id, | |
| 420 | - polls: 0, | |
| 421 | - done: false, | |
| 422 | - }, | |
| 423 | - |mut s| async move { | |
| 424 | - if s.done || s.polls >= SSE_MAX_POLLS { | |
| 425 | - return None; | |
| 426 | - } | |
| 427 | - if s.polls > 0 { | |
| 428 | - tokio::time::sleep(SSE_POLL_INTERVAL).await; | |
| 429 | - } | |
| 430 | - s.polls += 1; | |
| 431 | - | |
| 432 | - let state_str = match FediverseAuthService::status(&s.db, s.challenge_id).await { | |
| 433 | - Ok(model) => ChallengeStatus::parse(&model.status).as_str(), | |
| 434 | - Err(_) => "expired", | |
| 435 | - }; | |
| 436 | - let event = Event::default() | |
| 437 | - .event("status") | |
| 438 | - .data(format!(r#"{{"state":"{state_str}"}}"#)); | |
| 439 | - | |
| 440 | - // Mark stream as terminal when status is non-pending so the | |
| 441 | - // next iteration short-circuits. | |
| 442 | - if state_str != "pending" { | |
| 443 | - s.done = true; | |
| 444 | - } | |
| 445 | - Some((Ok::<_, Infallible>(event), s)) | |
| 446 | - }, | |
| 447 | - ) | |
| 448 | -} | |
| 449 | - | |
| 450 | -struct StreamState { | |
| 451 | - db: DatabaseConnection, | |
| 452 | - challenge_id: Uuid, | |
| 453 | - polls: u32, | |
| 454 | - done: bool, | |
| 455 | -} | |
| 456 | - | |
| 457 | -// ───────────────────────────────────────────────────────────────────── | |
| 458 | -// /fediverse/consent/{id} — IdP-side consent screen (iter. 4) | |
| 459 | -// ───────────────────────────────────────────────────────────────────── | |
| 460 | - | |
| 461 | -/// Form payload for `POST /fediverse/consent/{id}`. The hidden field | |
| 462 | -/// `decision` is `"approve"` or `"reject"` ; anything else is treated | |
| 463 | -/// as a rejection (defensive default). | |
| 464 | -#[derive(Debug, Deserialize)] | |
| 465 | -pub struct ConsentForm { | |
| 466 | - /// CSRF token from the hidden form field. | |
| 467 | - pub csrf_token: String, | |
| 468 | - /// `"approve"` or `"reject"`. | |
| 469 | - pub decision: String, | |
| 470 | -} | |
| 471 | - | |
| 472 | -/// Askama template for `GET /fediverse/consent/{id}`. | |
| 473 | -#[derive(Template)] | |
| 474 | -#[template(path = "fediverse/consent_screen.html")] | |
| 475 | -#[allow(dead_code, missing_docs)] | |
| 476 | -pub struct FediverseConsentTemplate { | |
| 477 | - pub current_locale: String, | |
| 478 | - pub page_title: String, | |
| 479 | - pub csrf_token: String, | |
| 480 | - pub heading: String, | |
| 481 | - pub explainer: String, | |
| 482 | - /// Domain of the requesting instance (`b.tld`). | |
| 483 | - pub requester_domain: String, | |
| 484 | - /// Handle the request claims to authenticate (`@alice@a.tld`). | |
| 485 | - pub handle: String, | |
| 486 | - pub field_label_requester: String, | |
| 487 | - pub field_label_handle: String, | |
| 488 | - pub approve_button: String, | |
| 489 | - pub reject_button: String, | |
| 490 | - /// `true` once the challenge has reached a terminal state — flips | |
| 491 | - /// the action area to a status banner instead of the form. | |
| 492 | - pub already_decided: bool, | |
| 493 | - pub already_decided_text: String, | |
| 494 | - /// Challenge UUID — embedded in the form action URL. | |
| 495 | - pub challenge_id: String, | |
| 496 | -} | |
| 497 | - | |
| 498 | -rustwarden_core::impl_template_response!(FediverseConsentTemplate); | |
| 499 | - | |
| 500 | -/// `GET /fediverse/consent/{challenge_id}` — render the consent screen | |
| 501 | -/// for a logged-in local user. | |
| 502 | -/// | |
| 503 | -/// The current implementation **renders** the screen but only fully | |
| 504 | -/// supports the *Reject* path. *Approve* requires the inbound auth | |
| 505 | -/// challenge to carry a `requested_remote_id`, which the inbox handler | |
| 506 | -/// will set in a follow-up to RFC 0006 (the existing inbox `auth_inbox` | |
| 507 | -/// at [`crate::fediverse::auth_routes`] documents this TODO at the | |
| 508 | -/// service-layer level). When `requested_remote_id` is absent the POST | |
| 509 | -/// handler refuses Approve with a clear i18n error. | |
| 510 | -pub async fn consent_form( | |
| 511 | - Extension(_claims): Extension<Claims>, | |
| 512 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 513 | - Extension(locale): Extension<Locale>, | |
| 514 | - State(db): State<DatabaseConnection>, | |
| 515 | - session: Session, | |
| 516 | - Path(challenge_id): Path<Uuid>, | |
| 517 | -) -> Result<axum::response::Response, AppError> { | |
| 518 | - let trust = FediverseTrustService::new(db.clone()); | |
| 519 | - if !trust.is_enabled().await? { | |
| 520 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 521 | - } | |
| 522 | - | |
| 523 | - let model = match FediverseAuthService::status(&db, challenge_id).await { | |
| 524 | - Ok(m) => m, | |
| 525 | - Err(_) => return Ok(axum::http::StatusCode::NOT_FOUND.into_response()), | |
| 526 | - }; | |
| 527 | - | |
| 528 | - let already_decided = ChallengeStatus::parse(&model.status) != ChallengeStatus::Pending; | |
| 529 | - | |
| 530 | - let locale_str = locale.as_str(); | |
| 531 | - let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 532 | - | |
| 533 | - let already_decided_text = if already_decided { | |
| 534 | - i18n_service | |
| 535 | - .translate("fediverse.auth.consent.already_decided", locale_str) | |
| 536 | - .await | |
| 537 | - } else { | |
| 538 | - String::new() | |
| 539 | - }; | |
| 540 | - | |
| 541 | - let template = FediverseConsentTemplate { | |
| 542 | - current_locale: locale_str.to_string(), | |
| 543 | - page_title: i18n_service | |
| 544 | - .translate("fediverse.auth.consent.page_title", locale_str) | |
| 545 | - .await, | |
| 546 | - csrf_token, | |
| 547 | - heading: i18n_service | |
| 548 | - .translate("fediverse.auth.consent.heading", locale_str) | |
| 549 | - .await, | |
| 550 | - explainer: i18n_service | |
| 551 | - .translate("fediverse.auth.consent.explainer", locale_str) | |
| 552 | - .await, | |
| 553 | - requester_domain: model.domain, | |
| 554 | - handle: model.handle, | |
| 555 | - field_label_requester: i18n_service | |
| 556 | - .translate("fediverse.auth.consent.label_requester", locale_str) | |
| 557 | - .await, | |
| 558 | - field_label_handle: i18n_service | |
| 559 | - .translate("fediverse.auth.consent.label_handle", locale_str) | |
| 560 | - .await, | |
| 561 | - approve_button: i18n_service | |
| 562 | - .translate("fediverse.auth.consent.approve", locale_str) | |
| 563 | - .await, | |
| 564 | - reject_button: i18n_service | |
| 565 | - .translate("fediverse.auth.consent.reject", locale_str) | |
| 566 | - .await, | |
| 567 | - already_decided, | |
| 568 | - already_decided_text, | |
| 569 | - challenge_id: challenge_id.to_string(), | |
| 570 | - }; | |
| 571 | - | |
| 572 | - Ok(template.into_response()) | |
| 573 | -} | |
| 574 | - | |
| 575 | -/// `POST /fediverse/consent/{challenge_id}` — record the user's | |
| 576 | -/// decision and redirect to `/home`. | |
| 577 | -/// | |
| 578 | -/// *Reject* always succeeds. *Approve* requires the challenge row to | |
| 579 | -/// have a `requested_remote_id` (set by the inbox handler when the | |
| 580 | -/// `AuthRequest` arrives). Without it we surface a Validation error | |
| 581 | -/// rather than mint an unbound approval, which would let an attacker | |
| 582 | -/// inject any `remote_identity_id` from the form. The follow-up RFC | |
| 583 | -/// 0006 Phase 2 will close the binding. | |
| 584 | -pub async fn consent_submit( | |
| 585 | - Extension(claims): Extension<Claims>, | |
| 586 | - State(db): State<DatabaseConnection>, | |
| 587 | - session: Session, | |
| 588 | - Path(challenge_id): Path<Uuid>, | |
| 589 | - Form(form): Form<ConsentForm>, | |
| 590 | -) -> Result<axum::response::Response, AppError> { | |
| 591 | - csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 592 | - | |
| 593 | - let trust = FediverseTrustService::new(db.clone()); | |
| 594 | - if !trust.is_enabled().await? { | |
| 595 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 596 | - } | |
| 597 | - | |
| 598 | - // Pre-fetch challenge: we need `nonce` + `requested_remote_id` both | |
| 599 | - // to feed `record_decision` and to enqueue the outbound back-channel | |
| 600 | - // (RFC 0025). Doing this once avoids a second roundtrip on approve. | |
| 601 | - let challenge = FediverseAuthService::status(&db, challenge_id).await?; | |
| 602 | - let remote_id = challenge.requested_remote_id.ok_or_else(|| { | |
| 603 | - AppError::Validation( | |
| 604 | - "auth challenge has no remote_identity_id binding — inbox \ | |
| 605 | - enrichment (RFC 0006 Phase 2) not yet wired" | |
| 606 | - .to_string(), | |
| 607 | - ) | |
| 608 | - })?; | |
| 609 | - | |
| 610 | - let approve_flag = form.decision.as_str() == "approve"; | |
| 611 | - let decision = if approve_flag { | |
| 612 | - ChallengeDecision::Approve { | |
| 613 | - remote_identity_id: remote_id, | |
| 614 | - } | |
| 615 | - } else { | |
| 616 | - ChallengeDecision::Reject | |
| 617 | - }; | |
| 618 | - | |
| 619 | - FediverseAuthService::record_decision(&db, challenge_id, decision).await?; | |
| 620 | - | |
| 621 | - // RFC 0025 — push the decision to B's inbox as Accept(AuthRequest) | |
| 622 | - // / Reject(AuthRequest). Failure to enqueue must not roll back the | |
| 623 | - // local decision (the row already moved past Pending), but we | |
| 624 | - // surface it so the operator sees the dead-letter / retry need. | |
| 625 | - if let Err(e) = enqueue_auth_decision(&db, &claims, &challenge, approve_flag).await { | |
| 626 | - tracing::error!( | |
| 627 | - challenge_id = %challenge_id, | |
| 628 | - decision = if approve_flag { "approve" } else { "reject" }, | |
| 629 | - error = %e, | |
| 630 | - "consent_submit: failed to enqueue back-channel activity", | |
| 631 | - ); | |
| 632 | - } | |
| 633 | - | |
| 634 | - Ok(Redirect::to("/home").into_response()) | |
| 635 | -} | |
| 636 | - | |
| 637 | -/// Build and enqueue the `Accept(AuthRequest)` or `Reject(AuthRequest)` | |
| 638 | -/// activity (RFC 0025) addressed to the requester's inbox. | |
| 639 | -/// | |
| 640 | -/// Resolves the requester via `challenge.requested_remote_id`. The | |
| 641 | -/// approver actor URL is derived from the IdP's `FediverseConfig` base | |
| 642 | -/// + the approver's username (in `Claims`). | |
| 643 | -async fn enqueue_auth_decision( | |
| 644 | - db: &DatabaseConnection, | |
| 645 | - approver: &Claims, | |
| 646 | - challenge: &fediverse_auth_challenge::Model, | |
| 647 | - approve: bool, | |
| 648 | -) -> Result<(), AppError> { | |
| 649 | - let remote_id = challenge.requested_remote_id.ok_or_else(|| { | |
| 650 | - AppError::Internal("challenge approved without requested_remote_id".into()) | |
| 651 | - })?; | |
| 652 | - | |
| 653 | - let requester = remote_identity::Entity::find_by_id(remote_id) | |
| 654 | - .one(db) | |
| 655 | - .await? | |
| 656 | - .ok_or_else(|| { | |
| 657 | - AppError::Internal(format!("remote_identity {remote_id} disappeared between checks")) | |
| 658 | - })?; | |
| 659 | - | |
| 660 | - let config = FediverseConfig::from_env() | |
| 661 | - .map_err(|e| AppError::Config(format!("FediverseConfig::from_env: {e}")))?; | |
| 662 | - let approver_actor_url = format!("{}users/{}", config.base_url(), approver.username); | |
| 663 | - | |
| 664 | - let payload = build_auth_decision_payload( | |
| 665 | - config.base_url(), | |
| 666 | - &approver_actor_url, | |
| 667 | - &requester.inbox_url, | |
| 668 | - &challenge.nonce, | |
| 669 | - approve, | |
| 670 | - ); | |
| 671 | - let activity_id = payload["id"].as_str().unwrap_or("").to_string(); | |
| 672 | - | |
| 673 | - FediverseOutboundService::enqueue( | |
| 674 | - db, | |
| 675 | - EnqueueRequest { | |
| 676 | - activity_id, | |
| 677 | - payload_json: serde_json::to_string(&payload) | |
| 678 | - .map_err(|e| AppError::Internal(format!("payload serialize: {e}")))?, | |
| 679 | - target_inbox: requester.inbox_url.clone(), | |
| 680 | - target_domain: requester.domain.clone(), | |
| 681 | - actor_user_id: Some(approver.sub), | |
| 682 | - }, | |
| 683 | - ) | |
| 684 | - .await?; | |
| 685 | - Ok(()) | |
| 686 | -} | |
| 687 | - | |
| 688 | -// ───────────────────────────────────────────────────────────────────── | |
| 689 | -// /profile/connected_apps (RFC 0024 Slice C iter. 5) | |
| 690 | -// ───────────────────────────────────────────────────────────────────── | |
| 691 | - | |
| 692 | -/// One row rendered into the connected-apps table — a remote instance | |
| 693 | -/// where the current user has an active federated session (= a | |
| 694 | -/// challenge in `approved` or `consumed` status). | |
| 695 | -#[derive(Debug, Clone)] | |
| 696 | -pub struct ConnectedAppRowCtx { | |
| 697 | - /// Domain of the requesting instance (`b.tld`). | |
| 698 | - pub domain: String, | |
| 699 | - /// Number of distinct sessions / approvals for that domain. | |
| 700 | - pub session_count: u64, | |
| 701 | - /// Most recent `created_at` for this domain, formatted | |
| 702 | - /// `YYYY-MM-DD HH:MM`. | |
| 703 | - pub last_seen: String, | |
| 704 | -} | |
| 705 | - | |
| 706 | -/// Askama template for `GET /profile/connected_apps`. | |
| 707 | -#[derive(Template)] | |
| 708 | -#[template(path = "profile/fediverse_connected_apps.html")] | |
| 709 | -#[allow(dead_code, missing_docs)] | |
| 710 | -pub struct ConnectedAppsTemplate { | |
| 711 | - pub current_locale: String, | |
| 712 | - pub page_title: String, | |
| 713 | - pub heading: String, | |
| 714 | - pub subtitle: String, | |
| 715 | - pub rows: Vec<ConnectedAppRowCtx>, | |
| 716 | - pub has_rows: bool, | |
| 717 | - pub table_domain: String, | |
| 718 | - pub table_count: String, | |
| 719 | - pub table_last_seen: String, | |
| 720 | - pub empty_title: String, | |
| 721 | - pub empty_body: String, | |
| 722 | - pub revoke_note: String, | |
| 723 | - pub back_to_profile: String, | |
| 724 | -} | |
| 725 | - | |
| 726 | -rustwarden_core::impl_template_response!(ConnectedAppsTemplate); | |
| 727 | - | |
| 728 | -/// `GET /profile/connected_apps` — section profil affichant la liste | |
| 729 | -/// des instances fédérées où l'utilisateur a actuellement une session. | |
| 730 | -/// | |
| 731 | -/// Mode dégradé silencieux (Plan UI §3 P9) : `404` quand | |
| 732 | -/// `fediverse_enabled = false`. Lecture seule en iter. 5 ; le bouton | |
| 733 | -/// « Révoquer » est différé à RFC 0006 Phase 2 (qui ajoutera la méthode | |
| 734 | -/// `FediverseAuthService::revoke_remote_session(challenge_id)` capable | |
| 735 | -/// de blacklister le JWT remote correspondant). | |
| 736 | -pub async fn connected_apps( | |
| 737 | - Extension(claims): Extension<Claims>, | |
| 738 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 739 | - Extension(locale): Extension<Locale>, | |
| 740 | - State(db): State<DatabaseConnection>, | |
| 741 | -) -> Result<axum::response::Response, AppError> { | |
| 742 | - let trust = FediverseTrustService::new(db.clone()); | |
| 743 | - if !trust.is_enabled().await? { | |
| 744 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 745 | - } | |
| 746 | - | |
| 747 | - // Build the local handle of the current user. | |
| 748 | - let domain_self = std::env::var("FEDIVERSE_DOMAIN") | |
| 749 | - .unwrap_or_else(|_| "localhost".to_string()); | |
| 750 | - let local_handle = format!("@{}@{}", claims.username, domain_self); | |
| 751 | - | |
| 752 | - // Pull all `approved` / `consumed` challenges for that handle. | |
| 753 | - let challenges = fediverse_auth_challenge::Entity::find() | |
| 754 | - .filter(fediverse_auth_challenge::Column::Handle.eq(local_handle)) | |
| 755 | - .filter( | |
| 756 | - fediverse_auth_challenge::Column::Status | |
| 757 | - .is_in(["approved", "consumed"]), | |
| 758 | - ) | |
| 759 | - .order_by_desc(fediverse_auth_challenge::Column::CreatedAt) | |
| 760 | - .all(&db) | |
| 761 | - .await?; | |
| 762 | - | |
| 763 | - // Group by domain: count + most recent created_at. | |
| 764 | - use std::collections::BTreeMap; | |
| 765 | - let mut grouped: BTreeMap<String, (u64, chrono::DateTime<chrono::Utc>)> = BTreeMap::new(); | |
| 766 | - for c in challenges { | |
| 767 | - let entry = grouped | |
| 768 | - .entry(c.domain.clone()) | |
| 769 | - .or_insert((0, c.created_at)); | |
| 770 | - entry.0 += 1; | |
| 771 | - if c.created_at > entry.1 { | |
| 772 | - entry.1 = c.created_at; | |
| 773 | - } | |
| 774 | - } | |
| 775 | - let mut rows: Vec<ConnectedAppRowCtx> = grouped | |
| 776 | - .into_iter() | |
| 777 | - .map(|(domain, (count, last))| ConnectedAppRowCtx { | |
| 778 | - domain, | |
| 779 | - session_count: count, | |
| 780 | - last_seen: last.format("%Y-%m-%d %H:%M").to_string(), | |
| 781 | - }) | |
| 782 | - .collect(); | |
| 783 | - // Most recently active first. | |
| 784 | - rows.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); | |
| 785 | - let has_rows = !rows.is_empty(); | |
| 786 | - | |
| 787 | - let locale_str = locale.as_str(); | |
| 788 | - let t = |k: &str| { | |
| 789 | - let i18n = i18n_service.clone(); | |
| 790 | - let key = k.to_string(); | |
| 791 | - let loc = locale_str.to_string(); | |
| 792 | - async move { i18n.translate(&key, &loc).await } | |
| 793 | - }; | |
| 794 | - | |
| 795 | - let template = ConnectedAppsTemplate { | |
| 796 | - current_locale: locale_str.to_string(), | |
| 797 | - page_title: t("fediverse_connected_apps.page_title").await, | |
| 798 | - heading: t("fediverse_connected_apps.heading").await, | |
| 799 | - subtitle: t("fediverse_connected_apps.subtitle").await, | |
| 800 | - rows, | |
| 801 | - has_rows, | |
| 802 | - table_domain: t("fediverse_connected_apps.table_domain").await, | |
| 803 | - table_count: t("fediverse_connected_apps.table_count").await, | |
| 804 | - table_last_seen: t("fediverse_connected_apps.table_last_seen").await, | |
| 805 | - empty_title: t("fediverse_connected_apps.empty_title").await, | |
| 806 | - empty_body: t("fediverse_connected_apps.empty_body").await, | |
| 807 | - revoke_note: t("fediverse_connected_apps.revoke_note").await, | |
| 808 | - back_to_profile: t("fediverse_connected_apps.back_to_profile").await, | |
| 809 | - }; | |
| 810 | - | |
| 811 | - Ok(template.into_response()) | |
| 812 | -} | |
| 813 | - | |
| 814 | -// ───────────────────────────────────────────────────────────────────── | |
| 815 | -// /fediverse/invite/{id} (RFC 0024 Slice C iter. 6) | |
| 816 | -// ───────────────────────────────────────────────────────────────────── | |
| 817 | - | |
| 818 | -/// Form payload for `POST /fediverse/invite/{id}`. | |
| 819 | -#[derive(Debug, Deserialize)] | |
| 820 | -pub struct InviteLandingForm { | |
| 821 | - /// CSRF token from the hidden form field. | |
| 822 | - pub csrf_token: String, | |
| 823 | - /// `"accept"` or `"reject"`. | |
| 824 | - pub decision: String, | |
| 825 | -} | |
| 826 | - | |
| 827 | -/// Askama template for `GET /fediverse/invite/{id}`. | |
| 828 | -#[derive(Template)] | |
| 829 | -#[template(path = "fediverse/invite_landing.html")] | |
| 830 | -#[allow(dead_code, missing_docs)] | |
| 831 | -pub struct InviteLandingTemplate { | |
| 832 | - pub current_locale: String, | |
| 833 | - pub page_title: String, | |
| 834 | - pub csrf_token: String, | |
| 835 | - pub heading: String, | |
| 836 | - pub explainer: String, | |
| 837 | - pub team_label: String, | |
| 838 | - pub team_name: String, | |
| 839 | - pub role_label: String, | |
| 840 | - pub role_value: String, | |
| 841 | - pub status_label: String, | |
| 842 | - pub status_value: String, | |
| 843 | - pub accept_button: String, | |
| 844 | - pub reject_button: String, | |
| 845 | - /// `true` when the invite is in a terminal state (Accepted / Rejected | |
| 846 | - /// / Revoked / Expired) — flips the UI to a status banner. | |
| 847 | - pub already_decided: bool, | |
| 848 | - pub already_decided_text: String, | |
| 849 | - /// Challenge UUID for form action. | |
| 850 | - pub invite_id: String, | |
| 851 | - /// `true` when the current user can accept (= remote identity bound). | |
| 852 | - pub can_accept: bool, | |
| 853 | - pub cannot_accept_note: String, | |
| 854 | -} | |
| 855 | - | |
| 856 | -rustwarden_core::impl_template_response!(InviteLandingTemplate); | |
| 857 | - | |
| 858 | -/// `GET /fediverse/invite/{invite_id}` — landing page for a federated | |
| 859 | -/// team invitation. | |
| 860 | -/// | |
| 861 | -/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` or | |
| 862 | -/// the invite does not exist. The current user (`Claims`) must be a | |
| 863 | -/// **Remote** identity to be able to accept — local users only see | |
| 864 | -/// the page in read-only mode (the form refuses to call `accept` | |
| 865 | -/// because `record_decision` requires `remote_identity_id`). | |
| 866 | -pub async fn invite_landing_form( | |
| 867 | - Extension(claims): Extension<Claims>, | |
| 868 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 869 | - Extension(locale): Extension<Locale>, | |
| 870 | - State(db): State<DatabaseConnection>, | |
| 871 | - session: Session, | |
| 872 | - axum::extract::Path(invite_id): axum::extract::Path<Uuid>, | |
| 873 | -) -> Result<axum::response::Response, AppError> { | |
| 874 | - let trust = FediverseTrustService::new(db.clone()); | |
| 875 | - if !trust.is_enabled().await? { | |
| 876 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 877 | - } | |
| 878 | - | |
| 879 | - let Some(invite) = fediverse_pending_invite::Entity::find_by_id(invite_id) | |
| 880 | - .one(&db) | |
| 881 | - .await? | |
| 882 | - else { | |
| 883 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Invite not found").into_response()); | |
| 884 | - }; | |
| 885 | - | |
| 886 | - // Load team name for display. | |
| 887 | - let team_name = rustwarden_core::models::team::Entity::find_by_id(invite.team_id) | |
| 888 | - .one(&db) | |
| 889 | - .await? | |
| 890 | - .map(|t| t.name) | |
| 891 | - .unwrap_or_else(|| invite.team_id.to_string()); | |
| 892 | - | |
| 893 | - // Status terminal? | |
| 894 | - use rustwarden_fediverse::services::fediverse_invite_service::InviteStatus; | |
| 895 | - let status_parsed = InviteStatus::parse(&invite.status); | |
| 896 | - let already_decided = status_parsed != InviteStatus::Pending; | |
| 897 | - | |
| 898 | - // Can accept = current user has a remote_identity_id (RFC 0007). | |
| 899 | - let can_accept = claims.is_remote() && claims.remote_identity_id().is_some(); | |
| 900 | - | |
| 901 | - let locale_str = locale.as_str(); | |
| 902 | - let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 903 | - | |
| 904 | - let t = |k: &str| { | |
| 905 | - let i18n = i18n_service.clone(); | |
| 906 | - let key = k.to_string(); | |
| 907 | - let loc = locale_str.to_string(); | |
| 908 | - async move { i18n.translate(&key, &loc).await } | |
| 909 | - }; | |
| 910 | - | |
| 911 | - let template = InviteLandingTemplate { | |
| 912 | - current_locale: locale_str.to_string(), | |
| 913 | - page_title: t("fediverse_invite_landing.page_title").await, | |
| 914 | - csrf_token, | |
| 915 | - heading: t("fediverse_invite_landing.heading").await, | |
| 916 | - explainer: t("fediverse_invite_landing.explainer").await, | |
| 917 | - team_label: t("fediverse_invite_landing.team_label").await, | |
| 918 | - team_name, | |
| 919 | - role_label: t("fediverse_invite_landing.role_label").await, | |
| 920 | - role_value: invite.role.clone(), | |
| 921 | - status_label: t("fediverse_invite_landing.status_label").await, | |
| 922 | - status_value: invite.status.clone(), | |
| 923 | - accept_button: t("fediverse_invite_landing.accept_button").await, | |
| 924 | - reject_button: t("fediverse_invite_landing.reject_button").await, | |
| 925 | - already_decided, | |
| 926 | - already_decided_text: if already_decided { | |
| 927 | - t("fediverse_invite_landing.already_decided").await | |
| 928 | - } else { | |
| 929 | - String::new() | |
| 930 | - }, | |
| 931 | - invite_id: invite_id.to_string(), | |
| 932 | - can_accept, | |
| 933 | - cannot_accept_note: if can_accept { | |
| 934 | - String::new() | |
| 935 | - } else { | |
| 936 | - t("fediverse_invite_landing.cannot_accept_note").await | |
| 937 | - }, | |
| 938 | - }; | |
| 939 | - | |
| 940 | - Ok(template.into_response()) | |
| 941 | -} | |
| 942 | - | |
| 943 | -/// `POST /fediverse/invite/{invite_id}` — record the invitee's decision. | |
| 944 | -/// | |
| 945 | -/// *Reject* always succeeds. *Accept* requires the current user to be a | |
| 946 | -/// **Remote** identity carrying a `remote_identity_id` claim — the | |
| 947 | -/// service inserts the `team_members` row with that FK. | |
| 948 | -pub async fn invite_landing_submit( | |
| 949 | - Extension(claims): Extension<Claims>, | |
| 950 | - State(db): State<DatabaseConnection>, | |
| 951 | - session: Session, | |
| 952 | - axum::extract::Path(invite_id): axum::extract::Path<Uuid>, | |
| 953 | - Form(form): Form<InviteLandingForm>, | |
| 954 | -) -> Result<axum::response::Response, AppError> { | |
| 955 | - csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 956 | - | |
| 957 | - let trust = FediverseTrustService::new(db.clone()); | |
| 958 | - if !trust.is_enabled().await? { | |
| 959 | - return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); | |
| 960 | - } | |
| 961 | - | |
| 962 | - match form.decision.as_str() { | |
| 963 | - "accept" => { | |
| 964 | - let remote_id = claims.remote_identity_id().ok_or_else(|| { | |
| 965 | - AppError::Validation( | |
| 966 | - "Only remote identities can accept federated invites; \ | |
| 967 | - local users must reject or ignore." | |
| 968 | - .into(), | |
| 969 | - ) | |
| 970 | - })?; | |
| 971 | - FediverseInviteService::accept(&db, invite_id, remote_id).await?; | |
| 972 | - } | |
| 973 | - // Anything other than "accept" is a reject (defensive default). | |
| 974 | - _ => { | |
| 975 | - FediverseInviteService::reject(&db, invite_id).await?; | |
| 976 | - } | |
| 977 | - } | |
| 978 | - | |
| 979 | - Ok(Redirect::to("/home").into_response()) | |
| 980 | -} | |
| 981 | - | |
| 982 | -#[cfg(test)] | |
| 983 | -mod tests { | |
| 984 | - use super::*; | |
| 985 | - | |
| 986 | - #[test] | |
| 987 | - fn error_reason_parses_known_values() { | |
| 988 | - assert_eq!(ErrorReason::parse("timeout"), ErrorReason::Timeout); | |
| 989 | - assert_eq!(ErrorReason::parse("rejected"), ErrorReason::Rejected); | |
| 990 | - assert_eq!(ErrorReason::parse("untrusted"), ErrorReason::Untrusted); | |
| 991 | - assert_eq!( | |
| 992 | - ErrorReason::parse("invalid_signature"), | |
| 993 | - ErrorReason::InvalidSignature | |
| 994 | - ); | |
| 995 | - assert_eq!( | |
| 996 | - ErrorReason::parse("invalid_sig"), | |
| 997 | - ErrorReason::InvalidSignature | |
| 998 | - ); | |
| 999 | - } | |
| 1000 | - | |
| 1001 | - #[test] | |
| 1002 | - fn error_reason_falls_back_to_unknown() { | |
| 1003 | - assert_eq!(ErrorReason::parse(""), ErrorReason::Unknown); | |
| 1004 | - assert_eq!(ErrorReason::parse("garbage"), ErrorReason::Unknown); | |
| 1005 | - assert_eq!(ErrorReason::parse("500"), ErrorReason::Unknown); | |
| 1006 | - } | |
| 1007 | - | |
| 1008 | - #[test] | |
| 1009 | - fn error_reason_trims_and_lowercases() { | |
| 1010 | - assert_eq!(ErrorReason::parse(" TIMEOUT "), ErrorReason::Timeout); | |
| 1011 | - assert_eq!( | |
| 1012 | - ErrorReason::parse("Invalid_Signature"), | |
| 1013 | - ErrorReason::InvalidSignature | |
| 1014 | - ); | |
| 1015 | - } | |
| 1016 | - | |
| 1017 | - #[test] | |
| 1018 | - fn extract_handle_domain_accepts_canonical() { | |
| 1019 | - assert_eq!( | |
| 1020 | - extract_handle_domain("@alice@example.org"), | |
| 1021 | - Some("example.org".to_string()) | |
| 1022 | - ); | |
| 1023 | - assert_eq!( | |
| 1024 | - extract_handle_domain("alice@example.org"), | |
| 1025 | - Some("example.org".to_string()) | |
| 1026 | - ); | |
| 1027 | - assert_eq!( | |
| 1028 | - extract_handle_domain(" @bob@MASTODON.SOCIAL "), | |
| 1029 | - Some("mastodon.social".to_string()) | |
| 1030 | - ); | |
| 1031 | - } | |
| 1032 | - | |
| 1033 | - #[test] | |
| 1034 | - fn extract_handle_domain_rejects_malformed() { | |
| 1035 | - assert_eq!(extract_handle_domain(""), None); | |
| 1036 | - assert_eq!(extract_handle_domain("alice"), None); | |
| 1037 | - assert_eq!(extract_handle_domain("alice@"), None); | |
| 1038 | - assert_eq!(extract_handle_domain("@@example.org"), None); | |
| 1039 | - } | |
| 1040 | - | |
| 1041 | - #[test] | |
| 1042 | - fn error_reason_round_trips_through_str() { | |
| 1043 | - for r in [ | |
| 1044 | - ErrorReason::Timeout, | |
| 1045 | - ErrorReason::Rejected, | |
| 1046 | - ErrorReason::Untrusted, | |
| 1047 | - ErrorReason::InvalidSignature, | |
| 1048 | - ErrorReason::Unknown, | |
| 1049 | - ] { | |
| 1050 | - assert_eq!(ErrorReason::parse(r.as_str()), r); | |
| 1051 | - } | |
| 1052 | - } | |
| 1053 | -} |
M
crates/rustwarden-ui/src/handlers/mod.rs
+0
-8
@@ -4,14 +4,6 @@
| 4 | 4 | |
| 5 | 5 | /// Admin panel handlers (users, roles, settings). |
| 6 | 6 | pub mod admin_handler; |
| 7 | -/// Admin handlers for the Fediverse foundations (RFC 0021): runtime | |
| 8 | -/// settings page (`/admin/fediverse/settings`). | |
| 9 | -#[cfg(feature = "fediverse")] | |
| 10 | -pub mod admin_fediverse; | |
| 11 | -/// User-facing handlers for the Fediverse auth flow (RFC 0023, Slice B): | |
| 12 | -/// error landing, login form, waiting page, consent screen. | |
| 13 | -#[cfg(feature = "fediverse")] | |
| 14 | -pub mod fediverse_user_auth; | |
| 15 | 7 | /// Dashboard widget handlers. |
| 16 | 8 | pub mod dashboard_handler; |
| 17 | 9 | /// Email delivery status handlers. |
M
crates/rustwarden-ui/src/routes.rs
+0
-119
@@ -59,33 +59,6 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 59 | 59 | .route("/health", get(health::health_check)) |
| 60 | 60 | .route("/login/2fa", get(login_handler::login_2fa_page)); |
| 61 | 61 | |
| 62 | - // Fediverse public auth routes (RFC 0023 Slice B). Gated by feature. | |
| 63 | - #[cfg(feature = "fediverse")] | |
| 64 | - let public_routes = { | |
| 65 | - use crate::handlers::fediverse_user_auth; | |
| 66 | - public_routes | |
| 67 | - .route( | |
| 68 | - "/login/fediverse/error", | |
| 69 | - get(fediverse_user_auth::error), | |
| 70 | - ) | |
| 71 | - .route( | |
| 72 | - "/login/fediverse", | |
| 73 | - get(fediverse_user_auth::login_form), | |
| 74 | - ) | |
| 75 | - .route( | |
| 76 | - "/login/fediverse", | |
| 77 | - post(fediverse_user_auth::login_initiate), | |
| 78 | - ) | |
| 79 | - .route( | |
| 80 | - "/login/fediverse/waiting/{challenge_id}", | |
| 81 | - get(fediverse_user_auth::waiting), | |
| 82 | - ) | |
| 83 | - .route( | |
| 84 | - "/login/fediverse/sse/{challenge_id}", | |
| 85 | - get(fediverse_user_auth::sse_status), | |
| 86 | - ) | |
| 87 | - }; | |
| 88 | - | |
| 89 | 62 | let ui_routes = Router::new() |
| 90 | 63 | .route("/home", get(home_handler::home)) |
| 91 | 64 | .route("/profile", get(profile::get_profile_page)) |
@@ -133,37 +106,6 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 133 | 106 | post(totp_handler::regenerate_backup_codes), |
| 134 | 107 | ); |
| 135 | 108 | |
| 136 | - // Fediverse logged-in routes (RFC 0023 Slice B iter. 4 + | |
| 137 | - // RFC 0024 Slice C iter. 5). Gated by feature. `/fediverse/consent` | |
| 138 | - // and `/profile/connected_apps` are both intercepted by | |
| 139 | - // `AuthService` via their respective prefixes so `Claims` is | |
| 140 | - // injected before the handler runs. | |
| 141 | - #[cfg(feature = "fediverse")] | |
| 142 | - let ui_routes = { | |
| 143 | - use crate::handlers::fediverse_user_auth; | |
| 144 | - ui_routes | |
| 145 | - .route( | |
| 146 | - "/fediverse/consent/{challenge_id}", | |
| 147 | - get(fediverse_user_auth::consent_form), | |
| 148 | - ) | |
| 149 | - .route( | |
| 150 | - "/fediverse/consent/{challenge_id}", | |
| 151 | - post(fediverse_user_auth::consent_submit), | |
| 152 | - ) | |
| 153 | - .route( | |
| 154 | - "/profile/connected_apps", | |
| 155 | - get(fediverse_user_auth::connected_apps), | |
| 156 | - ) | |
| 157 | - .route( | |
| 158 | - "/fediverse/invite/{invite_id}", | |
| 159 | - get(fediverse_user_auth::invite_landing_form), | |
| 160 | - ) | |
| 161 | - .route( | |
| 162 | - "/fediverse/invite/{invite_id}", | |
| 163 | - post(fediverse_user_auth::invite_landing_submit), | |
| 164 | - ) | |
| 165 | - }; | |
| 166 | - | |
| 167 | 109 | let admin_routes = Router::new() |
| 168 | 110 | .route("/admin", get(admin_handler::admin_dashboard)) |
| 169 | 111 | .route("/admin/users", get(admin_handler::list_users)) |
@@ -190,67 +132,6 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 190 | 132 | .route("/admin/settings", put(admin_handler::update_app_settings)) |
| 191 | 133 | .route("/admin/errors", get(admin_handler::admin_errors)); |
| 192 | 134 | |
| 193 | - // Fediverse admin routes (RFC 0021 Slice A) — gated by feature so | |
| 194 | - // apps without `experimental-fediverse` keep their attack surface | |
| 195 | - // unchanged. | |
| 196 | - #[cfg(feature = "fediverse")] | |
| 197 | - let admin_routes = { | |
| 198 | - use crate::handlers::admin_fediverse; | |
| 199 | - admin_routes | |
| 200 | - .route( | |
| 201 | - "/admin/fediverse/settings", | |
| 202 | - get(admin_fediverse::get_settings), | |
| 203 | - ) | |
| 204 | - .route( | |
| 205 | - "/admin/fediverse/settings", | |
| 206 | - post(admin_fediverse::update_settings), | |
| 207 | - ) | |
| 208 | - .route( | |
| 209 | - "/admin/fediverse/trust_list", | |
| 210 | - get(admin_fediverse::get_trust_list), | |
| 211 | - ) | |
| 212 | - .route( | |
| 213 | - "/admin/fediverse/trust_list/add", | |
| 214 | - post(admin_fediverse::add_trust_entry), | |
| 215 | - ) | |
| 216 | - .route( | |
| 217 | - "/admin/fediverse/trust_list/delete", | |
| 218 | - post(admin_fediverse::delete_trust_entry), | |
| 219 | - ) | |
| 220 | - .route( | |
| 221 | - "/admin/fediverse/queue", | |
| 222 | - get(admin_fediverse::get_queue), | |
| 223 | - ) | |
| 224 | - .route( | |
| 225 | - "/admin/fediverse/sessions_remote", | |
| 226 | - get(admin_fediverse::get_sessions_remote), | |
| 227 | - ) | |
| 228 | - .route( | |
| 229 | - "/admin/fediverse/sessions_remote/revoke", | |
| 230 | - post(admin_fediverse::revoke_remote_session), | |
| 231 | - ) | |
| 232 | - .route( | |
| 233 | - "/admin/fediverse/sessions_remote/kill_domain", | |
| 234 | - post(admin_fediverse::kill_domain), | |
| 235 | - ) | |
| 236 | - .route( | |
| 237 | - "/admin/fediverse/nodeinfo", | |
| 238 | - get(admin_fediverse::get_nodeinfo), | |
| 239 | - ) | |
| 240 | - .route( | |
| 241 | - "/admin/fediverse/invites/{team_id}", | |
| 242 | - get(admin_fediverse::get_team_invites), | |
| 243 | - ) | |
| 244 | - .route( | |
| 245 | - "/admin/fediverse/invites/{team_id}/add", | |
| 246 | - post(admin_fediverse::create_team_invite), | |
| 247 | - ) | |
| 248 | - .route( | |
| 249 | - "/admin/fediverse/invites/{team_id}/revoke", | |
| 250 | - post(admin_fediverse::revoke_team_invite), | |
| 251 | - ) | |
| 252 | - }; | |
| 253 | - | |
| 254 | 135 | let admin_routes = admin_routes |
| 255 | 136 | .route( |
| 256 | 137 | "/admin/users/{id}/reset-totp", |
M
crates/rustwarden-ui/templates/admin/dashboard.html
+5
-5
@@ -135,17 +135,17 @@
| 135 | 135 | </div> |
| 136 | 136 | </a> |
| 137 | 137 | |
| 138 | - {% if has_fediverse %} | |
| 139 | - <a href="/admin/fediverse/settings" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow"> | |
| 138 | + {% for card in extra_cards %} | |
| 139 | + <a href="{{ card.href }}" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow"> | |
| 140 | 140 | <div class="card-body p-4"> |
| 141 | - <h2 class="card-title text-info text-base">🌐 {{ fediverse_card_title }}</h2> | |
| 142 | - <p class="text-sm">{{ fediverse_card_desc }}</p> | |
| 141 | + <h2 class="card-title text-info text-base">{{ card.icon }} {{ card.title }}</h2> | |
| 142 | + <p class="text-sm">{{ card.desc }}</p> | |
| 143 | 143 | <div class="card-actions justify-end mt-2"> |
| 144 | 144 | <button class="btn btn-info btn-sm">{{ access_button }}</button> |
| 145 | 145 | </div> |
| 146 | 146 | </div> |
| 147 | 147 | </a> |
| 148 | - {% endif %} | |
| 148 | + {% endfor %} | |
| 149 | 149 | |
| 150 | 150 | </div> |
| 151 | 151 | </div> |
D
crates/rustwarden-ui/templates/admin/fediverse/nodeinfo.html
+0
-118
@@ -1,118 +0,0 @@
| 1 | -{% extends "base.html" %} | |
| 2 | - | |
| 3 | -{% block title %}{{ page_title }}{% endblock %} | |
| 4 | - | |
| 5 | -{% block navbar %} | |
| 6 | -{% include "components/navbar.html" %} | |
| 7 | -{% endblock %} | |
| 8 | - | |
| 9 | -{% block content %} | |
| 10 | -<div class="container mx-auto px-4 py-8"> | |
| 11 | - | |
| 12 | - <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 13 | - <ul> | |
| 14 | - <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 15 | - <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 16 | - <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 17 | - </ul> | |
| 18 | - </nav> | |
| 19 | - | |
| 20 | - <header class="mb-8"> | |
| 21 | - <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 22 | - <p class="text-base-content/70">{{ subtitle }}</p> | |
| 23 | - </header> | |
| 24 | - | |
| 25 | - <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| 26 | - | |
| 27 | - {# Software card #} | |
| 28 | - <section class="card bg-base-100 shadow-xl" aria-labelledby="card-sw"> | |
| 29 | - <div class="card-body"> | |
| 30 | - <h2 id="card-sw" class="card-title">{{ card_software }}</h2> | |
| 31 | - <dl class="space-y-2 mt-2"> | |
| 32 | - <div class="flex justify-between"> | |
| 33 | - <dt class="text-base-content/60">{{ label_name }}</dt> | |
| 34 | - <dd class="font-mono">{{ software_name }}</dd> | |
| 35 | - </div> | |
| 36 | - <div class="flex justify-between"> | |
| 37 | - <dt class="text-base-content/60">{{ label_version }}</dt> | |
| 38 | - <dd class="font-mono">{{ software_version }}</dd> | |
| 39 | - </div> | |
| 40 | - <div class="flex justify-between"> | |
| 41 | - <dt class="text-base-content/60">{{ label_domain }}</dt> | |
| 42 | - <dd class="font-mono">{{ fediverse_domain }}</dd> | |
| 43 | - </div> | |
| 44 | - </dl> | |
| 45 | - </div> | |
| 46 | - </section> | |
| 47 | - | |
| 48 | - {# Runtime config card #} | |
| 49 | - <section class="card bg-base-100 shadow-xl" aria-labelledby="card-rt"> | |
| 50 | - <div class="card-body"> | |
| 51 | - <h2 id="card-rt" class="card-title">{{ card_runtime }}</h2> | |
| 52 | - <dl class="space-y-2 mt-2"> | |
| 53 | - <div class="flex justify-between"> | |
| 54 | - <dt class="text-base-content/60">{{ label_enabled }}</dt> | |
| 55 | - <dd><span class="badge badge-success">{{ yes_label }}</span></dd> | |
| 56 | - </div> | |
| 57 | - <div class="flex justify-between"> | |
| 58 | - <dt class="text-base-content/60">{{ label_trust_mode }}</dt> | |
| 59 | - <dd class="font-mono">{{ trust_mode }}</dd> | |
| 60 | - </div> | |
| 61 | - <div class="flex justify-between"> | |
| 62 | - <dt class="text-base-content/60">{{ label_interop_scope }}</dt> | |
| 63 | - <dd class="font-mono">{{ interop_scope }}</dd> | |
| 64 | - </div> | |
| 65 | - <div class="flex justify-between"> | |
| 66 | - <dt class="text-base-content/60">{{ label_authorized_fetch }}</dt> | |
| 67 | - <dd> | |
| 68 | - {% if authorized_fetch %} | |
| 69 | - <span class="badge badge-success">{{ yes_label }}</span> | |
| 70 | - {% else %} | |
| 71 | - <span class="badge">{{ no_label }}</span> | |
| 72 | - {% endif %} | |
| 73 | - </dd> | |
| 74 | - </div> | |
| 75 | - </dl> | |
| 76 | - </div> | |
| 77 | - </section> | |
| 78 | - | |
| 79 | - {# Local users card #} | |
| 80 | - <section class="card bg-base-100 shadow-xl" aria-labelledby="card-us"> | |
| 81 | - <div class="card-body"> | |
| 82 | - <h2 id="card-us" class="card-title">{{ card_users }}</h2> | |
| 83 | - <div class="grid grid-cols-3 gap-2 mt-2"> | |
| 84 | - <div class="stat p-2"> | |
| 85 | - <div class="stat-title text-xs">{{ label_users_total }}</div> | |
| 86 | - <div class="stat-value text-2xl">{{ users_total }}</div> | |
| 87 | - </div> | |
| 88 | - <div class="stat p-2"> | |
| 89 | - <div class="stat-title text-xs">{{ label_users_active_month }}</div> | |
| 90 | - <div class="stat-value text-2xl text-success">{{ users_active_month }}</div> | |
| 91 | - </div> | |
| 92 | - <div class="stat p-2"> | |
| 93 | - <div class="stat-title text-xs">{{ label_users_active_halfyear }}</div> | |
| 94 | - <div class="stat-value text-2xl">{{ users_active_halfyear }}</div> | |
| 95 | - </div> | |
| 96 | - </div> | |
| 97 | - </div> | |
| 98 | - </section> | |
| 99 | - | |
| 100 | - {# Remote identities card #} | |
| 101 | - <section class="card bg-base-100 shadow-xl" aria-labelledby="card-rm"> | |
| 102 | - <div class="card-body"> | |
| 103 | - <h2 id="card-rm" class="card-title">{{ card_remotes }}</h2> | |
| 104 | - <div class="grid grid-cols-2 gap-2 mt-2"> | |
| 105 | - <div class="stat p-2"> | |
| 106 | - <div class="stat-title text-xs">{{ label_remote_identities_active }}</div> | |
| 107 | - <div class="stat-value text-2xl">{{ remote_identities_active }}</div> | |
| 108 | - </div> | |
| 109 | - <div class="stat p-2"> | |
| 110 | - <div class="stat-title text-xs">{{ label_remote_distinct_domains }}</div> | |
| 111 | - <div class="stat-value text-2xl">{{ remote_distinct_domains }}</div> | |
| 112 | - </div> | |
| 113 | - </div> | |
| 114 | - </div> | |
| 115 | - </section> | |
| 116 | - </div> | |
| 117 | -</div> | |
| 118 | -{% endblock %} |
D
crates/rustwarden-ui/templates/admin/fediverse/queue.html
+0
-100
@@ -1,100 +0,0 @@
| 1 | -{% extends "base.html" %} | |
| 2 | - | |
| 3 | -{% block title %}{{ page_title }}{% endblock %} | |
| 4 | - | |
| 5 | -{% block navbar %} | |
| 6 | -{% include "components/navbar.html" %} | |
| 7 | -{% endblock %} | |
| 8 | - | |
| 9 | -{% block content %} | |
| 10 | -<div class="container mx-auto px-4 py-8"> | |
| 11 | - | |
| 12 | - <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 13 | - <ul> | |
| 14 | - <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 15 | - <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 16 | - <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 17 | - </ul> | |
| 18 | - </nav> | |
| 19 | - | |
| 20 | - <header class="mb-8"> | |
| 21 | - <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 22 | - <p class="text-base-content/70">{{ subtitle }}</p> | |
| 23 | - </header> | |
| 24 | - | |
| 25 | - {# Stats grid (Plan UI §3 P4 — empty states inclusive) #} | |
| 26 | - <section class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8" aria-label="stats"> | |
| 27 | - <div class="stat bg-base-100 shadow rounded-lg"> | |
| 28 | - <div class="stat-title">{{ stats_pending }}</div> | |
| 29 | - <div class="stat-value text-warning">{{ count_pending }}</div> | |
| 30 | - </div> | |
| 31 | - <div class="stat bg-base-100 shadow rounded-lg"> | |
| 32 | - <div class="stat-title">{{ stats_delivered }}</div> | |
| 33 | - <div class="stat-value text-success">{{ count_delivered }}</div> | |
| 34 | - </div> | |
| 35 | - <div class="stat bg-base-100 shadow rounded-lg"> | |
| 36 | - <div class="stat-title">{{ stats_failed }}</div> | |
| 37 | - <div class="stat-value text-error">{{ count_failed }}</div> | |
| 38 | - </div> | |
| 39 | - <div class="stat bg-base-100 shadow rounded-lg"> | |
| 40 | - <div class="stat-title">{{ stats_dead }}</div> | |
| 41 | - <div class="stat-value">{{ count_dead }}</div> | |
| 42 | - </div> | |
| 43 | - </section> | |
| 44 | - | |
| 45 | - {% if has_rows %} | |
| 46 | - <section class="card bg-base-100 shadow-xl"> | |
| 47 | - <div class="card-body"> | |
| 48 | - <div class="overflow-x-auto"> | |
| 49 | - <table class="table table-zebra table-sm"> | |
| 50 | - <thead> | |
| 51 | - <tr> | |
| 52 | - <th>{{ table_activity }}</th> | |
| 53 | - <th>{{ table_target }}</th> | |
| 54 | - <th>{{ table_status }}</th> | |
| 55 | - <th>{{ table_attempts }}</th> | |
| 56 | - <th>{{ table_last_error }}</th> | |
| 57 | - <th>{{ table_next_retry }}</th> | |
| 58 | - <th>{{ table_created_at }}</th> | |
| 59 | - </tr> | |
| 60 | - </thead> | |
| 61 | - <tbody> | |
| 62 | - {% for row in rows %} | |
| 63 | - <tr> | |
| 64 | - <td class="font-mono text-xs">{{ row.activity_id }}</td> | |
| 65 | - <td class="font-mono text-xs">{{ row.target_inbox }}</td> | |
| 66 | - <td> | |
| 67 | - {% if row.status == "pending" %} | |
| 68 | - <span class="badge badge-warning">{{ row.status }}</span> | |
| 69 | - {% else if row.status == "delivered" %} | |
| 70 | - <span class="badge badge-success">{{ row.status }}</span> | |
| 71 | - {% else if row.status == "failed" %} | |
| 72 | - <span class="badge badge-error">{{ row.status }}</span> | |
| 73 | - {% else %} | |
| 74 | - <span class="badge">{{ row.status }}</span> | |
| 75 | - {% endif %} | |
| 76 | - </td> | |
| 77 | - <td>{{ row.attempts }}/{{ row.max_attempts }}</td> | |
| 78 | - <td class="text-xs max-w-xs truncate"> | |
| 79 | - {% if row.has_last_error %}{{ row.last_error }}{% else %}—{% endif %} | |
| 80 | - </td> | |
| 81 | - <td class="text-xs text-base-content/70">{{ row.next_retry_at }}</td> | |
| 82 | - <td class="text-xs text-base-content/70">{{ row.created_at }}</td> | |
| 83 | - </tr> | |
| 84 | - {% endfor %} | |
| 85 | - </tbody> | |
| 86 | - </table> | |
| 87 | - </div> | |
| 88 | - </div> | |
| 89 | - </section> | |
| 90 | - {% else %} | |
| 91 | - <section class="card bg-base-200 shadow-inner" aria-label="empty-state"> | |
| 92 | - <div class="card-body items-center text-center py-16"> | |
| 93 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> | |
| 94 | - <h3 class="text-lg font-semibold">{{ empty_title }}</h3> | |
| 95 | - <p class="text-base-content/70 max-w-md">{{ empty_body }}</p> | |
| 96 | - </div> | |
| 97 | - </section> | |
| 98 | - {% endif %} | |
| 99 | -</div> | |
| 100 | -{% endblock %} |
D
crates/rustwarden-ui/templates/admin/fediverse/sessions_remote.html
+0
-162
@@ -1,162 +0,0 @@
| 1 | -{% extends "base.html" %} | |
| 2 | - | |
| 3 | -{% block title %}{{ page_title }}{% endblock %} | |
| 4 | - | |
| 5 | -{% block navbar %} | |
| 6 | -{% include "components/navbar.html" %} | |
| 7 | -{% endblock %} | |
| 8 | - | |
| 9 | -{% block content %} | |
| 10 | -<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %}, revokeTarget: null, killDomain: null }"> | |
| 11 | - | |
| 12 | - <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 13 | - <ul> | |
| 14 | - <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 15 | - <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 16 | - <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 17 | - </ul> | |
| 18 | - </nav> | |
| 19 | - | |
| 20 | - <header class="mb-8"> | |
| 21 | - <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 22 | - <p class="text-base-content/70">{{ subtitle }}</p> | |
| 23 | - </header> | |
| 24 | - | |
| 25 | - {# Toast on save #} | |
| 26 | - <div | |
| 27 | - x-show="saved" | |
| 28 | - x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })" | |
| 29 | - x-transition | |
| 30 | - role="status" | |
| 31 | - aria-live="polite" | |
| 32 | - class="alert alert-success mb-6" | |
| 33 | - > | |
| 34 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 35 | - <span>{{ saved_toast }}</span> | |
| 36 | - </div> | |
| 37 | - | |
| 38 | - {# Kill switch by domain (Plan UI §3 P1: one primary action per card) #} | |
| 39 | - {% if has_rows %} | |
| 40 | - <section class="card bg-base-100 shadow-xl mb-8 border-l-4 border-error" aria-labelledby="kill-legend"> | |
| 41 | - <div class="card-body"> | |
| 42 | - <h2 id="kill-legend" class="card-title text-xl mb-4 text-error">{{ kill_switch_legend }}</h2> | |
| 43 | - <div class="form-control"> | |
| 44 | - <label class="label" for="kill-domain"> | |
| 45 | - <span class="label-text font-semibold">{{ kill_switch_label }}</span> | |
| 46 | - </label> | |
| 47 | - <div class="flex gap-2 max-w-md"> | |
| 48 | - <select id="kill-domain" x-model="killDomain" class="select select-bordered flex-1"> | |
| 49 | - <option value=""></option> | |
| 50 | - {% for d in distinct_domains %} | |
| 51 | - <option value="{{ d }}">{{ d }}</option> | |
| 52 | - {% endfor %} | |
| 53 | - </select> | |
| 54 | - <button | |
| 55 | - type="button" | |
| 56 | - class="btn btn-error" | |
| 57 | - :disabled="!killDomain" | |
| 58 | - @click="if (killDomain) { document.getElementById('kill-domain-modal').showModal() }" | |
| 59 | - > | |
| 60 | - {{ kill_switch_button }} | |
| 61 | - </button> | |
| 62 | - </div> | |
| 63 | - </div> | |
| 64 | - </div> | |
| 65 | - </section> | |
| 66 | - {% endif %} | |
| 67 | - | |
| 68 | - {# Sessions table #} | |
| 69 | - {% if has_rows %} | |
| 70 | - <section class="card bg-base-100 shadow-xl"> | |
| 71 | - <div class="card-body"> | |
| 72 | - <div class="overflow-x-auto"> | |
| 73 | - <table class="table table-zebra"> | |
| 74 | - <thead> | |
| 75 | - <tr> | |
| 76 | - <th>{{ table_handle }}</th> | |
| 77 | - <th>{{ table_display_name }}</th> | |
| 78 | - <th>{{ table_domain }}</th> | |
| 79 | - <th>{{ table_last_seen }}</th> | |
| 80 | - <th class="text-right">{{ table_actions }}</th> | |
| 81 | - </tr> | |
| 82 | - </thead> | |
| 83 | - <tbody> | |
| 84 | - {% for row in rows %} | |
| 85 | - <tr> | |
| 86 | - <td class="font-mono">{{ row.handle }}</td> | |
| 87 | - <td>{% if row.has_display_name %}{{ row.display_name }}{% else %}—{% endif %}</td> | |
| 88 | - <td><span class="badge badge-ghost">{{ row.domain }}</span></td> | |
| 89 | - <td class="text-base-content/70">{{ row.last_seen }}</td> | |
| 90 | - <td class="text-right"> | |
| 91 | - <button | |
| 92 | - type="button" | |
| 93 | - class="btn btn-sm btn-ghost text-error" | |
| 94 | - @click="revokeTarget = '{{ row.id }}|{{ row.handle }}'" | |
| 95 | - aria-haspopup="dialog" | |
| 96 | - > | |
| 97 | - {{ revoke_button }} | |
| 98 | - </button> | |
| 99 | - </td> | |
| 100 | - </tr> | |
| 101 | - {% endfor %} | |
| 102 | - </tbody> | |
| 103 | - </table> | |
| 104 | - </div> | |
| 105 | - </div> | |
| 106 | - </section> | |
| 107 | - {% else %} | |
| 108 | - <section class="card bg-base-200 shadow-inner" aria-label="empty-state"> | |
| 109 | - <div class="card-body items-center text-center py-16"> | |
| 110 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg> | |
| 111 | - <h3 class="text-lg font-semibold">{{ empty_title }}</h3> | |
| 112 | - <p class="text-base-content/70 max-w-md">{{ empty_body }}</p> | |
| 113 | - </div> | |
| 114 | - </section> | |
| 115 | - {% endif %} | |
| 116 | - | |
| 117 | - {# Revoke individual session modal (Plan UI §3 P2) #} | |
| 118 | - <dialog | |
| 119 | - class="modal" | |
| 120 | - :class="{ 'modal-open': revokeTarget !== null }" | |
| 121 | - role="alertdialog" | |
| 122 | - aria-labelledby="revoke-title" | |
| 123 | - > | |
| 124 | - <div class="modal-box"> | |
| 125 | - <h3 id="revoke-title" class="font-bold text-lg">{{ revoke_confirm_title }}</h3> | |
| 126 | - <p class="py-4"> | |
| 127 | - {{ revoke_confirm_body }} | |
| 128 | - <span class="font-mono block mt-2" x-text="revokeTarget ? revokeTarget.split('|')[1] : ''"></span> | |
| 129 | - </p> | |
| 130 | - <form method="POST" action="/admin/fediverse/sessions_remote/revoke" class="modal-action"> | |
| 131 | - <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 132 | - <input type="hidden" name="id" :value="revokeTarget ? revokeTarget.split('|')[0] : ''"> | |
| 133 | - <button type="button" class="btn btn-ghost" @click="revokeTarget = null">{{ modal_cancel }}</button> | |
| 134 | - <button type="submit" class="btn btn-error">{{ modal_confirm }}</button> | |
| 135 | - </form> | |
| 136 | - </div> | |
| 137 | - <form method="dialog" class="modal-backdrop"> | |
| 138 | - <button type="button" @click="revokeTarget = null" aria-label="close">close</button> | |
| 139 | - </form> | |
| 140 | - </dialog> | |
| 141 | - | |
| 142 | - {# Kill switch domain modal (extra-destructive — DaisyUI native dialog) #} | |
| 143 | - <dialog id="kill-domain-modal" class="modal" role="alertdialog" aria-labelledby="kill-modal-title"> | |
| 144 | - <div class="modal-box"> | |
| 145 | - <h3 id="kill-modal-title" class="font-bold text-lg text-error">{{ kill_switch_confirm_title }}</h3> | |
| 146 | - <p class="py-4"> | |
| 147 | - {{ kill_switch_confirm_body }} | |
| 148 | - <span class="font-mono block mt-2" x-text="killDomain"></span> | |
| 149 | - </p> | |
| 150 | - <form method="POST" action="/admin/fediverse/sessions_remote/kill_domain" class="modal-action"> | |
| 151 | - <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 152 | - <input type="hidden" name="domain" :value="killDomain"> | |
| 153 | - <button type="button" class="btn btn-ghost" @click="document.getElementById('kill-domain-modal').close()">{{ modal_cancel }}</button> | |
| 154 | - <button type="submit" class="btn btn-error">{{ modal_confirm }}</button> | |
| 155 | - </form> | |
| 156 | - </div> | |
| 157 | - <form method="dialog" class="modal-backdrop"> | |
| 158 | - <button type="button" aria-label="close">close</button> | |
| 159 | - </form> | |
| 160 | - </dialog> | |
| 161 | -</div> | |
| 162 | -{% endblock %} |
D
crates/rustwarden-ui/templates/admin/fediverse/settings.html
+0
-138
@@ -1,138 +0,0 @@
| 1 | -{% extends "base.html" %} | |
| 2 | - | |
| 3 | -{% block title %}{{ page_title }}{% endblock %} | |
| 4 | - | |
| 5 | -{% block navbar %} | |
| 6 | -{% include "components/navbar.html" %} | |
| 7 | -{% endblock %} | |
| 8 | - | |
| 9 | -{% block content %} | |
| 10 | -<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %} }"> | |
| 11 | - | |
| 12 | - {# Breadcrumb (a11y P7: parent landmarks) #} | |
| 13 | - <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 14 | - <ul> | |
| 15 | - <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 16 | - <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 17 | - <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 18 | - </ul> | |
| 19 | - </nav> | |
| 20 | - | |
| 21 | - {# Page header #} | |
| 22 | - <header class="mb-8"> | |
| 23 | - <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 24 | - <p class="text-base-content/70">{{ subtitle }}</p> | |
| 25 | - </header> | |
| 26 | - | |
| 27 | - {# Save toast (Plan UI §3 P3) — auto-dismisses after 4 s #} | |
| 28 | - <div | |
| 29 | - x-show="saved" | |
| 30 | - x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })" | |
| 31 | - x-transition | |
| 32 | - role="status" | |
| 33 | - aria-live="polite" | |
| 34 | - class="alert alert-success mb-6" | |
| 35 | - > | |
| 36 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 37 | - <span>{{ saved_toast }}</span> | |
| 38 | - </div> | |
| 39 | - | |
| 40 | - <form method="POST" action="/admin/fediverse/settings" class="space-y-6 max-w-3xl"> | |
| 41 | - <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 42 | - | |
| 43 | - {# Card: Activation #} | |
| 44 | - <section class="card bg-base-100 shadow-xl" aria-labelledby="card-activation"> | |
| 45 | - <div class="card-body"> | |
| 46 | - <h2 id="card-activation" class="card-title text-xl mb-4">{{ card_activation }}</h2> | |
| 47 | - <label class="label cursor-pointer justify-start gap-4" for="fediverse_enabled"> | |
| 48 | - <input | |
| 49 | - id="fediverse_enabled" | |
| 50 | - type="checkbox" | |
| 51 | - name="fediverse_enabled" | |
| 52 | - value="true" | |
| 53 | - class="checkbox checkbox-primary" | |
| 54 | - {% if settings.fediverse_enabled == "true" %}checked{% endif %} | |
| 55 | - > | |
| 56 | - <span class="flex flex-col"> | |
| 57 | - <span class="label-text font-semibold">{{ enabled_label }}</span> | |
| 58 | - <span class="label-text-alt text-base-content/60">{{ enabled_desc }}</span> | |
| 59 | - </span> | |
| 60 | - </label> | |
| 61 | - </div> | |
| 62 | - </section> | |
| 63 | - | |
| 64 | - {# Card: Trust mode #} | |
| 65 | - <section class="card bg-base-100 shadow-xl" aria-labelledby="card-trust"> | |
| 66 | - <div class="card-body"> | |
| 67 | - <h2 id="card-trust" class="card-title text-xl mb-4">{{ card_trust }}</h2> | |
| 68 | - <label class="label" for="fediverse_trust_mode"> | |
| 69 | - <span class="label-text font-semibold">{{ trust_mode_label }}</span> | |
| 70 | - </label> | |
| 71 | - <select | |
| 72 | - id="fediverse_trust_mode" | |
| 73 | - name="fediverse_trust_mode" | |
| 74 | - class="select select-bordered w-full max-w-md" | |
| 75 | - > | |
| 76 | - <option value="allowlist" {% if settings.fediverse_trust_mode == "allowlist" %}selected{% endif %}> | |
| 77 | - {{ trust_mode_allowlist }} | |
| 78 | - </option> | |
| 79 | - <option value="open_with_blocklist" {% if settings.fediverse_trust_mode == "open_with_blocklist" %}selected{% endif %}> | |
| 80 | - {{ trust_mode_open }} | |
| 81 | - </option> | |
| 82 | - </select> | |
| 83 | - </div> | |
| 84 | - </section> | |
| 85 | - | |
| 86 | - {# Card: Interop scope #} | |
| 87 | - <section class="card bg-base-100 shadow-xl" aria-labelledby="card-interop"> | |
| 88 | - <div class="card-body"> | |
| 89 | - <h2 id="card-interop" class="card-title text-xl mb-4">{{ card_interop }}</h2> | |
| 90 | - <label class="label" for="fediverse_interop_scope"> | |
| 91 | - <span class="label-text font-semibold">{{ interop_scope_label }}</span> | |
| 92 | - </label> | |
| 93 | - <select | |
| 94 | - id="fediverse_interop_scope" | |
| 95 | - name="fediverse_interop_scope" | |
| 96 | - class="select select-bordered w-full max-w-md" | |
| 97 | - > | |
| 98 | - <option value="rustwarden_only" {% if settings.fediverse_interop_scope == "rustwarden_only" %}selected{% endif %}> | |
| 99 | - {{ interop_scope_rustwarden_only }} | |
| 100 | - </option> | |
| 101 | - <option value="rustwarden_plus_universal_read" {% if settings.fediverse_interop_scope == "rustwarden_plus_universal_read" %}selected{% endif %}> | |
| 102 | - {{ interop_scope_rustwarden_plus }} | |
| 103 | - </option> | |
| 104 | - <option value="open" {% if settings.fediverse_interop_scope == "open" %}selected{% endif %}> | |
| 105 | - {{ interop_scope_open }} | |
| 106 | - </option> | |
| 107 | - </select> | |
| 108 | - </div> | |
| 109 | - </section> | |
| 110 | - | |
| 111 | - {# Card: Advanced security #} | |
| 112 | - <section class="card bg-base-100 shadow-xl" aria-labelledby="card-security"> | |
| 113 | - <div class="card-body"> | |
| 114 | - <h2 id="card-security" class="card-title text-xl mb-4">{{ card_security }}</h2> | |
| 115 | - <label class="label cursor-pointer justify-start gap-4" for="fediverse_authorized_fetch"> | |
| 116 | - <input | |
| 117 | - id="fediverse_authorized_fetch" | |
| 118 | - type="checkbox" | |
| 119 | - name="fediverse_authorized_fetch" | |
| 120 | - value="true" | |
| 121 | - class="checkbox checkbox-primary" | |
| 122 | - {% if settings.fediverse_authorized_fetch == "true" %}checked{% endif %} | |
| 123 | - > | |
| 124 | - <span class="flex flex-col"> | |
| 125 | - <span class="label-text font-semibold">{{ authorized_fetch_label }}</span> | |
| 126 | - <span class="label-text-alt text-base-content/60">{{ authorized_fetch_desc }}</span> | |
| 127 | - </span> | |
| 128 | - </label> | |
| 129 | - </div> | |
| 130 | - </section> | |
| 131 | - | |
| 132 | - {# Single primary CTA (Plan UI §3 P1) #} | |
| 133 | - <div class="flex justify-end"> | |
| 134 | - <button type="submit" class="btn btn-primary">{{ save_button }}</button> | |
| 135 | - </div> | |
| 136 | - </form> | |
| 137 | -</div> | |
| 138 | -{% endblock %} |
D
crates/rustwarden-ui/templates/admin/fediverse/team_invites.html
+0
-167
@@ -1,167 +0,0 @@
| 1 | -{% extends "base.html" %} | |
| 2 | - | |
| 3 | -{% block title %}{{ page_title }}{% endblock %} | |
| 4 | - | |
| 5 | -{% block navbar %} | |
| 6 | -{% include "components/navbar.html" %} | |
| 7 | -{% endblock %} | |
| 8 | - | |
| 9 | -{% block content %} | |
| 10 | -<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %}, revokeTarget: null }"> | |
| 11 | - | |
| 12 | - <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 13 | - <ul> | |
| 14 | - <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 15 | - <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 16 | - <li aria-current="page">{{ breadcrumb_self }}</li> | |
| 17 | - </ul> | |
| 18 | - </nav> | |
| 19 | - | |
| 20 | - <header class="mb-8"> | |
| 21 | - <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 22 | - <p class="text-base-content/70">{{ subtitle }} <span class="font-mono">{{ team_name }}</span></p> | |
| 23 | - </header> | |
| 24 | - | |
| 25 | - <div | |
| 26 | - x-show="saved" | |
| 27 | - x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })" | |
| 28 | - x-transition | |
| 29 | - role="status" | |
| 30 | - aria-live="polite" | |
| 31 | - class="alert alert-success mb-6" | |
| 32 | - > | |
| 33 | - <span>{{ saved_toast }}</span> | |
| 34 | - </div> | |
| 35 | - | |
| 36 | - {# Add form #} | |
| 37 | - <section class="card bg-base-100 shadow-xl mb-8" aria-labelledby="add-legend"> | |
| 38 | - <div class="card-body"> | |
| 39 | - <h2 id="add-legend" class="card-title text-xl mb-4">{{ add_form_legend }}</h2> | |
| 40 | - <form method="POST" action="/admin/fediverse/invites/{{ team_id }}/add" class="space-y-4"> | |
| 41 | - <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 42 | - | |
| 43 | - <div class="form-control"> | |
| 44 | - <label class="label" for="invite-handle"> | |
| 45 | - <span class="label-text font-semibold">{{ handle_label }}</span> | |
| 46 | - </label> | |
| 47 | - <input | |
| 48 | - id="invite-handle" | |
| 49 | - type="text" | |
| 50 | - name="handle" | |
| 51 | - required | |
| 52 | - autocomplete="off" | |
| 53 | - autocapitalize="off" | |
| 54 | - spellcheck="false" | |
| 55 | - pattern="^@?[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$" | |
| 56 | - class="input input-bordered w-full max-w-md" | |
| 57 | - placeholder="{{ handle_placeholder }}" | |
| 58 | - > | |
| 59 | - </div> | |
| 60 | - | |
| 61 | - <div class="form-control"> | |
| 62 | - <label class="label" for="invite-role"> | |
| 63 | - <span class="label-text font-semibold">{{ role_label }}</span> | |
| 64 | - </label> | |
| 65 | - <select id="invite-role" name="role" required class="select select-bordered w-full max-w-md"> | |
| 66 | - <option value="member">{{ role_member }}</option> | |
| 67 | - <option value="admin">{{ role_admin }}</option> | |
| 68 | - <option value="owner">{{ role_owner }}</option> | |
| 69 | - </select> | |
| 70 | - </div> | |
| 71 | - | |
| 72 | - <div class="flex justify-end"> | |
| 73 | - <button type="submit" class="btn btn-primary">{{ add_button }}</button> | |
| 74 | - </div> | |
| 75 | - </form> | |
| 76 | - </div> | |
| 77 | - </section> | |
| 78 | - | |
| 79 | - {# Table or empty state #} | |
| 80 | - {% if has_rows %} | |
| 81 | - <section class="card bg-base-100 shadow-xl"> | |
| 82 | - <div class="card-body"> | |
| 83 | - <div class="overflow-x-auto"> | |
| 84 | - <table class="table table-zebra"> | |
| 85 | - <thead> | |
| 86 | - <tr> | |
| 87 | - <th>{{ table_handle }}</th> | |
| 88 | - <th>{{ table_role }}</th> | |
| 89 | - <th>{{ table_status }}</th> | |
| 90 | - <th>{{ table_created_at }}</th> | |
| 91 | - <th class="text-right">{{ table_actions }}</th> | |
| 92 | - </tr> | |
| 93 | - </thead> | |
| 94 | - <tbody> | |
| 95 | - {% for row in rows %} | |
| 96 | - <tr> | |
| 97 | - <td class="font-mono">{{ row.invited_handle }}</td> | |
| 98 | - <td><span class="badge badge-ghost">{{ row.role }}</span></td> | |
| 99 | - <td> | |
| 100 | - {% if row.status == "pending" %} | |
| 101 | - <span class="badge badge-warning">{{ row.status }}</span> | |
| 102 | - {% else if row.status == "accepted" %} | |
| 103 | - <span class="badge badge-success">{{ row.status }}</span> | |
| 104 | - {% else if row.status == "rejected" || row.status == "revoked" || row.status == "expired" %} | |
| 105 | - <span class="badge badge-error">{{ row.status }}</span> | |
| 106 | - {% else %} | |
| 107 | - <span class="badge">{{ row.status }}</span> | |
| 108 | - {% endif %} | |
| 109 | - </td> | |
| 110 | - <td class="text-base-content/70">{{ row.created_at }}</td> | |
| 111 | - <td class="text-right"> | |
| 112 | - {% if row.status == "pending" || row.status == "accepted" %} | |
| 113 | - <button | |
| 114 | - type="button" | |
| 115 | - class="btn btn-sm btn-ghost text-error" | |
| 116 | - @click="revokeTarget = '{{ row.id }}|{{ row.invited_handle }}'" | |
| 117 | - aria-haspopup="dialog" | |
| 118 | - > | |
| 119 | - {{ revoke_button }} | |
| 120 | - </button> | |
| 121 | - {% else %} | |
| 122 | - — | |
| 123 | - {% endif %} | |
| 124 | - </td> | |
| 125 | - </tr> | |
| 126 | - {% endfor %} | |
| 127 | - </tbody> | |
| 128 | - </table> | |
| 129 | - </div> | |
| 130 | - </div> | |
| 131 | - </section> | |
| 132 | - {% else %} | |
| 133 | - <section class="card bg-base-200 shadow-inner" aria-label="empty-state"> | |
| 134 | - <div class="card-body items-center text-center py-16"> | |
| 135 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> | |
| 136 | - <h3 class="text-lg font-semibold">{{ empty_title }}</h3> | |
| 137 | - <p class="text-base-content/70 max-w-md">{{ empty_body }}</p> | |
| 138 | - </div> | |
| 139 | - </section> | |
| 140 | - {% endif %} | |
| 141 | - | |
| 142 | - {# Revoke modal #} | |
| 143 | - <dialog | |
| 144 | - class="modal" | |
| 145 | - :class="{ 'modal-open': revokeTarget !== null }" | |
| 146 | - role="alertdialog" | |
| 147 | - aria-labelledby="revoke-title" | |
| 148 | - > | |
| 149 | - <div class="modal-box"> | |
| 150 | - <h3 id="revoke-title" class="font-bold text-lg">{{ revoke_confirm_title }}</h3> | |
| 151 | - <p class="py-4"> | |
| 152 | - {{ revoke_confirm_body }} | |
| 153 | - <span class="font-mono block mt-2" x-text="revokeTarget ? revokeTarget.split('|')[1] : ''"></span> | |
| 154 | - </p> | |
| 155 | - <form method="POST" action="/admin/fediverse/invites/{{ team_id }}/revoke" class="modal-action"> | |
| 156 | - <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 157 | - <input type="hidden" name="id" :value="revokeTarget ? revokeTarget.split('|')[0] : ''"> | |
| 158 | - <button type="button" class="btn btn-ghost" @click="revokeTarget = null">{{ modal_cancel }}</button> | |
| 159 | - <button type="submit" class="btn btn-error">{{ modal_confirm }}</button> | |
| 160 | - </form> | |
| 161 | - </div> | |
| 162 | - <form method="dialog" class="modal-backdrop"> | |
| 163 | - <button type="button" @click="revokeTarget = null" aria-label="close">close</button> | |
| 164 | - </form> | |
| 165 | - </dialog> | |
| 166 | -</div> | |
| 167 | -{% endblock %} |
D
crates/rustwarden-ui/templates/admin/fediverse/trust_list.html
+0
-188
@@ -1,188 +0,0 @@
| 1 | -{% extends "base.html" %} | |
| 2 | - | |
| 3 | -{% block title %}{{ page_title }}{% endblock %} | |
| 4 | - | |
| 5 | -{% block navbar %} | |
| 6 | -{% include "components/navbar.html" %} | |
| 7 | -{% endblock %} | |
| 8 | - | |
| 9 | -{% block content %} | |
| 10 | -<div class="container mx-auto px-4 py-8" x-data="{ saved: {% if saved_flash %}true{% else %}false{% endif %}, deleteTarget: null }"> | |
| 11 | - | |
| 12 | - {# Breadcrumb #} | |
| 13 | - <nav class="text-sm breadcrumbs mb-6" aria-label="breadcrumb"> | |
| 14 | - <ul> | |
| 15 | - <li><a href="/">{{ breadcrumb_home }}</a></li> | |
| 16 | - <li><a href="/admin">{{ breadcrumb_admin }}</a></li> | |
| 17 | - <li><a href="/admin/fediverse/settings">{{ breadcrumb_self }}</a></li> | |
| 18 | - <li aria-current="page"> | |
| 19 | - {% if is_blocklist %}{{ table_severity }}{% else %}{{ table_note_or_reason }}{% endif %} | |
| 20 | - </li> | |
| 21 | - </ul> | |
| 22 | - </nav> | |
| 23 | - | |
| 24 | - {# Page header #} | |
| 25 | - <header class="mb-2"> | |
| 26 | - <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 27 | - <p class="text-base-content/70">{{ subtitle }}</p> | |
| 28 | - </header> | |
| 29 | - | |
| 30 | - {# Mode indicator badge #} | |
| 31 | - <div class="mb-6"> | |
| 32 | - <span class="badge {% if is_blocklist %}badge-warning{% else %}badge-success{% endif %} badge-lg"> | |
| 33 | - {{ mode_indicator }} | |
| 34 | - </span> | |
| 35 | - </div> | |
| 36 | - | |
| 37 | - {# Toast on save (Plan UI §3 P3) #} | |
| 38 | - <div | |
| 39 | - x-show="saved" | |
| 40 | - x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })" | |
| 41 | - x-transition | |
| 42 | - role="status" | |
| 43 | - aria-live="polite" | |
| 44 | - class="alert alert-success mb-6" | |
| 45 | - > | |
| 46 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 47 | - <span>{{ saved_toast }}</span> | |
| 48 | - </div> | |
| 49 | - | |
| 50 | - {# Add form #} | |
| 51 | - <section class="card bg-base-100 shadow-xl mb-8" aria-labelledby="add-legend"> | |
| 52 | - <div class="card-body"> | |
| 53 | - <h2 id="add-legend" class="card-title text-xl mb-4">{{ add_form_legend }}</h2> | |
| 54 | - <form method="POST" action="/admin/fediverse/trust_list/add" class="space-y-4"> | |
| 55 | - <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 56 | - | |
| 57 | - <div class="form-control"> | |
| 58 | - <label class="label" for="trust-domain"> | |
| 59 | - <span class="label-text font-semibold">{{ domain_label }}</span> | |
| 60 | - </label> | |
| 61 | - <input | |
| 62 | - id="trust-domain" | |
| 63 | - type="text" | |
| 64 | - name="domain" | |
| 65 | - required | |
| 66 | - autocomplete="off" | |
| 67 | - class="input input-bordered w-full max-w-md" | |
| 68 | - placeholder="{{ domain_placeholder }}" | |
| 69 | - > | |
| 70 | - </div> | |
| 71 | - | |
| 72 | - <div class="form-control"> | |
| 73 | - <label class="label" for="trust-note"> | |
| 74 | - <span class="label-text font-semibold"> | |
| 75 | - {% if is_blocklist %}{{ reason_label }}{% else %}{{ note_label }}{% endif %} | |
| 76 | - </span> | |
| 77 | - </label> | |
| 78 | - <input | |
| 79 | - id="trust-note" | |
| 80 | - type="text" | |
| 81 | - name="note_or_reason" | |
| 82 | - autocomplete="off" | |
| 83 | - class="input input-bordered w-full max-w-md" | |
| 84 | - > | |
| 85 | - </div> | |
| 86 | - | |
| 87 | - {% if is_blocklist %} | |
| 88 | - <div class="form-control"> | |
| 89 | - <label class="label" for="trust-severity"> | |
| 90 | - <span class="label-text font-semibold">{{ severity_label }}</span> | |
| 91 | - </label> | |
| 92 | - <select | |
| 93 | - id="trust-severity" | |
| 94 | - name="severity" | |
| 95 | - required | |
| 96 | - class="select select-bordered w-full max-w-md" | |
| 97 | - > | |
| 98 | - <option value="suspend">{{ severity_suspend }}</option> | |
| 99 | - <option value="silence">{{ severity_silence }}</option> | |
| 100 | - <option value="reject_auth">{{ severity_reject_auth }}</option> | |
| 101 | - </select> | |
| 102 | - </div> | |
| 103 | - {% endif %} | |
| 104 | - | |
| 105 | - <div class="flex justify-end"> | |
| 106 | - <button type="submit" class="btn btn-primary">{{ add_button }}</button> | |
| 107 | - </div> | |
| 108 | - </form> | |
| 109 | - </div> | |
| 110 | - </section> | |
| 111 | - | |
| 112 | - {# Table or empty state #} | |
| 113 | - {% if has_entries %} | |
| 114 | - <section class="card bg-base-100 shadow-xl"> | |
| 115 | - <div class="card-body"> | |
| 116 | - <div class="overflow-x-auto"> | |
| 117 | - <table class="table table-zebra"> | |
| 118 | - <thead> | |
| 119 | - <tr> | |
| 120 | - <th>{{ table_domain }}</th> | |
| 121 | - <th>{{ table_note_or_reason }}</th> | |
| 122 | - {% if is_blocklist %}<th>{{ table_severity }}</th>{% endif %} | |
| 123 | - <th>{{ table_created_at }}</th> | |
| 124 | - <th class="text-right">{{ table_actions }}</th> | |
| 125 | - </tr> | |
| 126 | - </thead> | |
| 127 | - <tbody> | |
| 128 | - {% for entry in entries %} | |
| 129 | - <tr> | |
| 130 | - <td class="font-mono">{{ entry.domain }}</td> | |
| 131 | - <td>{% if entry.has_note_or_reason %}{{ entry.note_or_reason }}{% else %}—{% endif %}</td> | |
| 132 | - {% if is_blocklist %}<td>{{ entry.severity }}</td>{% endif %} | |
| 133 | - <td class="text-base-content/70">{{ entry.created_at }}</td> | |
| 134 | - <td class="text-right"> | |
| 135 | - <button | |
| 136 | - type="button" | |
| 137 | - class="btn btn-sm btn-ghost text-error" | |
| 138 | - @click="deleteTarget = '{{ entry.domain }}'" | |
| 139 | - aria-haspopup="dialog" | |
| 140 | - > | |
| 141 | - {{ delete_button }} | |
| 142 | - </button> | |
| 143 | - </td> | |
| 144 | - </tr> | |
| 145 | - {% endfor %} | |
| 146 | - </tbody> | |
| 147 | - </table> | |
| 148 | - </div> | |
| 149 | - </div> | |
| 150 | - </section> | |
| 151 | - {% else %} | |
| 152 | - {# Empty state (Plan UI §3 P4) #} | |
| 153 | - <section class="card bg-base-200 shadow-inner" aria-label="empty-state"> | |
| 154 | - <div class="card-body items-center text-center py-16"> | |
| 155 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> | |
| 156 | - <h3 class="text-lg font-semibold">{{ empty_title }}</h3> | |
| 157 | - <p class="text-base-content/70 max-w-md">{{ empty_body }}</p> | |
| 158 | - </div> | |
| 159 | - </section> | |
| 160 | - {% endif %} | |
| 161 | - | |
| 162 | - {# Confirmation modal (Plan UI §3 P2). Triggered by `deleteTarget`. #} | |
| 163 | - <dialog | |
| 164 | - class="modal" | |
| 165 | - :class="{ 'modal-open': deleteTarget !== null }" | |
| 166 | - role="alertdialog" | |
| 167 | - aria-labelledby="confirm-title" | |
| 168 | - aria-describedby="confirm-body" | |
| 169 | - > | |
| 170 | - <div class="modal-box"> | |
| 171 | - <h3 id="confirm-title" class="font-bold text-lg">{{ delete_confirm_title }}</h3> | |
| 172 | - <p id="confirm-body" class="py-4"> | |
| 173 | - {{ delete_confirm_body }} | |
| 174 | - <span class="font-mono block mt-2" x-text="deleteTarget"></span> | |
| 175 | - </p> | |
| 176 | - <form method="POST" action="/admin/fediverse/trust_list/delete" class="modal-action"> | |
| 177 | - <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 178 | - <input type="hidden" name="domain" :value="deleteTarget"> | |
| 179 | - <button type="button" class="btn btn-ghost" @click="deleteTarget = null">{{ modal_cancel }}</button> | |
| 180 | - <button type="submit" class="btn btn-error">{{ modal_confirm }}</button> | |
| 181 | - </form> | |
| 182 | - </div> | |
| 183 | - <form method="dialog" class="modal-backdrop"> | |
| 184 | - <button type="button" @click="deleteTarget = null" aria-label="close">close</button> | |
| 185 | - </form> | |
| 186 | - </dialog> | |
| 187 | -</div> | |
| 188 | -{% endblock %} |
D
crates/rustwarden-ui/templates/fediverse/consent_screen.html
+0
-50
@@ -1,50 +0,0 @@
| 1 | -<!DOCTYPE html> | |
| 2 | -<html lang="{{ current_locale }}"> | |
| 3 | -<head> | |
| 4 | - <meta charset="UTF-8"> | |
| 5 | - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | - <title>{{ page_title }}</title> | |
| 7 | - <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | - <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | -</head> | |
| 10 | -<body class="min-h-screen bg-base-200 flex items-center justify-center px-4"> | |
| 11 | - <main class="card bg-base-100 shadow-xl max-w-lg w-full"> | |
| 12 | - <div class="card-body"> | |
| 13 | - <header class="mb-4"> | |
| 14 | - <h1 class="card-title text-2xl">{{ heading }}</h1> | |
| 15 | - <p class="text-base-content/70 mt-1">{{ explainer }}</p> | |
| 16 | - </header> | |
| 17 | - | |
| 18 | - <dl class="space-y-3 mb-6"> | |
| 19 | - <div> | |
| 20 | - <dt class="text-sm text-base-content/60">{{ field_label_requester }}</dt> | |
| 21 | - <dd class="font-mono text-base">{{ requester_domain }}</dd> | |
| 22 | - </div> | |
| 23 | - <div> | |
| 24 | - <dt class="text-sm text-base-content/60">{{ field_label_handle }}</dt> | |
| 25 | - <dd class="font-mono text-base">{{ handle }}</dd> | |
| 26 | - </div> | |
| 27 | - </dl> | |
| 28 | - | |
| 29 | - {% if already_decided %} | |
| 30 | - <div role="status" aria-live="polite" class="alert alert-info"> | |
| 31 | - <span>{{ already_decided_text }}</span> | |
| 32 | - </div> | |
| 33 | - <div class="card-actions justify-end mt-4"> | |
| 34 | - <a href="/home" class="btn btn-primary">OK</a> | |
| 35 | - </div> | |
| 36 | - {% else %} | |
| 37 | - <form method="POST" action="/fediverse/consent/{{ challenge_id }}" class="card-actions justify-end gap-2"> | |
| 38 | - <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 39 | - <button type="submit" name="decision" value="reject" class="btn btn-ghost text-error"> | |
| 40 | - {{ reject_button }} | |
| 41 | - </button> | |
| 42 | - <button type="submit" name="decision" value="approve" class="btn btn-primary"> | |
| 43 | - {{ approve_button }} | |
| 44 | - </button> | |
| 45 | - </form> | |
| 46 | - {% endif %} | |
| 47 | - </div> | |
| 48 | - </main> | |
| 49 | -</body> | |
| 50 | -</html> |
D
crates/rustwarden-ui/templates/fediverse/error_unauthorized.html
+0
-33
@@ -1,33 +0,0 @@
| 1 | -<!DOCTYPE html> | |
| 2 | -<html lang="{{ current_locale }}"> | |
| 3 | -<head> | |
| 4 | - <meta charset="UTF-8"> | |
| 5 | - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | - <meta name="description" content="{{ subtitle }}"> | |
| 7 | - <title>{{ page_title }}</title> | |
| 8 | - <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 9 | - <link href="/static/css/custom.css" rel="stylesheet"> | |
| 10 | -</head> | |
| 11 | -<body class="min-h-screen bg-base-200 flex items-center justify-center px-4"> | |
| 12 | - <main class="card bg-base-100 shadow-xl max-w-xl w-full"> | |
| 13 | - <div class="card-body items-center text-center"> | |
| 14 | - {# Icon shifts with reason: alert-triangle for blocked variants, | |
| 15 | - clock for timeout, lock for invalid_signature. #} | |
| 16 | - {% if reason == "timeout" %} | |
| 17 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-warning mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 18 | - {% else if reason == "invalid_signature" %} | |
| 19 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-error mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> | |
| 20 | - {% else %} | |
| 21 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-error mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> | |
| 22 | - {% endif %} | |
| 23 | - | |
| 24 | - <h1 class="card-title text-2xl mb-2">{{ heading }}</h1> | |
| 25 | - <p class="text-base-content/80 mb-6">{{ body }}</p> | |
| 26 | - | |
| 27 | - <div class="card-actions"> | |
| 28 | - <a href="/login" class="btn btn-primary">{{ back_to_login }}</a> | |
| 29 | - </div> | |
| 30 | - </div> | |
| 31 | - </main> | |
| 32 | -</body> | |
| 33 | -</html> |
D
crates/rustwarden-ui/templates/fediverse/invite_landing.html
+0
-75
@@ -1,75 +0,0 @@
| 1 | -<!DOCTYPE html> | |
| 2 | -<html lang="{{ current_locale }}"> | |
| 3 | -<head> | |
| 4 | - <meta charset="UTF-8"> | |
| 5 | - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | - <title>{{ page_title }}</title> | |
| 7 | - <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | - <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | -</head> | |
| 10 | -<body class="min-h-screen bg-base-200 flex items-center justify-center px-4"> | |
| 11 | - <main class="card bg-base-100 shadow-xl max-w-lg w-full"> | |
| 12 | - <div class="card-body"> | |
| 13 | - <header class="mb-4"> | |
| 14 | - <h1 class="card-title text-2xl">{{ heading }}</h1> | |
| 15 | - <p class="text-base-content/70 mt-1">{{ explainer }}</p> | |
| 16 | - </header> | |
| 17 | - | |
| 18 | - <dl class="space-y-3 mb-6"> | |
| 19 | - <div> | |
| 20 | - <dt class="text-sm text-base-content/60">{{ team_label }}</dt> | |
| 21 | - <dd class="font-mono text-base">{{ team_name }}</dd> | |
| 22 | - </div> | |
| 23 | - <div> | |
| 24 | - <dt class="text-sm text-base-content/60">{{ role_label }}</dt> | |
| 25 | - <dd> | |
| 26 | - <span class="badge badge-ghost">{{ role_value }}</span> | |
| 27 | - </dd> | |
| 28 | - </div> | |
| 29 | - <div> | |
| 30 | - <dt class="text-sm text-base-content/60">{{ status_label }}</dt> | |
| 31 | - <dd> | |
| 32 | - {% if status_value == "pending" %} | |
| 33 | - <span class="badge badge-warning">{{ status_value }}</span> | |
| 34 | - {% else if status_value == "accepted" %} | |
| 35 | - <span class="badge badge-success">{{ status_value }}</span> | |
| 36 | - {% else if status_value == "rejected" || status_value == "revoked" || status_value == "expired" %} | |
| 37 | - <span class="badge badge-error">{{ status_value }}</span> | |
| 38 | - {% else %} | |
| 39 | - <span class="badge">{{ status_value }}</span> | |
| 40 | - {% endif %} | |
| 41 | - </dd> | |
| 42 | - </div> | |
| 43 | - </dl> | |
| 44 | - | |
| 45 | - {% if already_decided %} | |
| 46 | - <div role="status" aria-live="polite" class="alert alert-info"> | |
| 47 | - <span>{{ already_decided_text }}</span> | |
| 48 | - </div> | |
| 49 | - <div class="card-actions justify-end mt-4"> | |
| 50 | - <a href="/home" class="btn btn-primary">OK</a> | |
| 51 | - </div> | |
| 52 | - {% else %} | |
| 53 | - {% if !can_accept %} | |
| 54 | - <div role="status" aria-live="polite" class="alert alert-warning mb-4"> | |
| 55 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> | |
| 56 | - <span>{{ cannot_accept_note }}</span> | |
| 57 | - </div> | |
| 58 | - {% endif %} | |
| 59 | - | |
| 60 | - <form method="POST" action="/fediverse/invite/{{ invite_id }}" class="card-actions justify-end gap-2"> | |
| 61 | - <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 62 | - <button type="submit" name="decision" value="reject" class="btn btn-ghost text-error"> | |
| 63 | - {{ reject_button }} | |
| 64 | - </button> | |
| 65 | - {% if can_accept %} | |
| 66 | - <button type="submit" name="decision" value="accept" class="btn btn-primary"> | |
| 67 | - {{ accept_button }} | |
| 68 | - </button> | |
| 69 | - {% endif %} | |
| 70 | - </form> | |
| 71 | - {% endif %} | |
| 72 | - </div> | |
| 73 | - </main> | |
| 74 | -</body> | |
| 75 | -</html> |
D
crates/rustwarden-ui/templates/fediverse/login_form.html
+0
-53
@@ -1,53 +0,0 @@
| 1 | -<!DOCTYPE html> | |
| 2 | -<html lang="{{ current_locale }}"> | |
| 3 | -<head> | |
| 4 | - <meta charset="UTF-8"> | |
| 5 | - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | - <meta name="description" content="{{ subtitle }}"> | |
| 7 | - <title>{{ page_title }}</title> | |
| 8 | - <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 9 | - <link href="/static/css/custom.css" rel="stylesheet"> | |
| 10 | -</head> | |
| 11 | -<body class="min-h-screen bg-base-200 flex items-center justify-center px-4"> | |
| 12 | - <main class="card bg-base-100 shadow-xl max-w-md w-full"> | |
| 13 | - <div class="card-body"> | |
| 14 | - <header class="mb-4"> | |
| 15 | - <h1 class="card-title text-2xl">{{ heading }}</h1> | |
| 16 | - <p class="text-base-content/70 mt-1">{{ subtitle }}</p> | |
| 17 | - </header> | |
| 18 | - | |
| 19 | - <form method="POST" action="/login/fediverse" class="space-y-4" novalidate> | |
| 20 | - <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 21 | - | |
| 22 | - <div class="form-control"> | |
| 23 | - <label class="label" for="fediverse-handle"> | |
| 24 | - <span class="label-text font-semibold">{{ field_label }}</span> | |
| 25 | - </label> | |
| 26 | - <input | |
| 27 | - id="fediverse-handle" | |
| 28 | - type="text" | |
| 29 | - name="handle" | |
| 30 | - required | |
| 31 | - autocomplete="off" | |
| 32 | - autocapitalize="off" | |
| 33 | - spellcheck="false" | |
| 34 | - inputmode="email" | |
| 35 | - pattern="^@?[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$" | |
| 36 | - class="input input-bordered w-full" | |
| 37 | - placeholder="{{ field_placeholder }}" | |
| 38 | - aria-describedby="fediverse-handle-help" | |
| 39 | - > | |
| 40 | - <div id="fediverse-handle-help" class="label"> | |
| 41 | - <span class="label-text-alt text-base-content/60">{{ field_help }}</span> | |
| 42 | - </div> | |
| 43 | - </div> | |
| 44 | - | |
| 45 | - <div class="card-actions justify-between mt-2"> | |
| 46 | - <a href="/login" class="btn btn-ghost">{{ back_to_login }}</a> | |
| 47 | - <button type="submit" class="btn btn-primary">{{ submit_button }}</button> | |
| 48 | - </div> | |
| 49 | - </form> | |
| 50 | - </div> | |
| 51 | - </main> | |
| 52 | -</body> | |
| 53 | -</html> |
D
crates/rustwarden-ui/templates/fediverse/login_waiting.html
+0
-79
@@ -1,79 +0,0 @@
| 1 | -<!DOCTYPE html> | |
| 2 | -<html lang="{{ current_locale }}"> | |
| 3 | -<head> | |
| 4 | - <meta charset="UTF-8"> | |
| 5 | - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | - <meta name="description" content="{{ subtitle }}"> | |
| 7 | - <title>{{ page_title }}</title> | |
| 8 | - <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 9 | - <link href="/static/css/custom.css" rel="stylesheet"> | |
| 10 | -</head> | |
| 11 | -<body class="min-h-screen bg-base-200 flex items-center justify-center px-4"> | |
| 12 | - <main class="card bg-base-100 shadow-xl max-w-md w-full" role="status" aria-live="polite"> | |
| 13 | - <div class="card-body items-center text-center py-10"> | |
| 14 | - <span class="loading loading-spinner loading-lg text-primary mb-4"></span> | |
| 15 | - <h1 class="card-title text-2xl mb-2">{{ heading }}</h1> | |
| 16 | - <p class="text-base-content/80 mb-6">{{ explainer }}</p> | |
| 17 | - <a href="/login" class="btn btn-ghost btn-sm">{{ cancel_button }}</a> | |
| 18 | - </div> | |
| 19 | - </main> | |
| 20 | - | |
| 21 | - <script> | |
| 22 | - // RFC 0023 Slice B iter. 3 — Server-Sent Events listener. | |
| 23 | - // Pas de framework JS lourd : EventSource natif suffit. | |
| 24 | - (function() { | |
| 25 | - const challengeId = "{{ challenge_id }}"; | |
| 26 | - const sse = new EventSource("/login/fediverse/sse/" + challengeId); | |
| 27 | - | |
| 28 | - sse.addEventListener("status", function(ev) { | |
| 29 | - let payload; | |
| 30 | - try { | |
| 31 | - payload = JSON.parse(ev.data); | |
| 32 | - } catch (_) { | |
| 33 | - return; | |
| 34 | - } | |
| 35 | - const state = payload.state; | |
| 36 | - if (state === "approved") { | |
| 37 | - // L'IdP a approuvé : appeler /api/v1/auth/login/fediverse/consume/{id} | |
| 38 | - // qui pose le cookie JWT remote, puis redirection /home. | |
| 39 | - fetch("/api/v1/auth/login/fediverse/consume/" + challengeId, { | |
| 40 | - method: "POST", | |
| 41 | - credentials: "same-origin", | |
| 42 | - }) | |
| 43 | - .then(function(r) { | |
| 44 | - if (r.ok) { | |
| 45 | - window.location.href = "/home"; | |
| 46 | - } else { | |
| 47 | - window.location.href = "/login/fediverse/error?reason=unknown"; | |
| 48 | - } | |
| 49 | - }) | |
| 50 | - .catch(function() { | |
| 51 | - window.location.href = "/login/fediverse/error?reason=unknown"; | |
| 52 | - }); | |
| 53 | - sse.close(); | |
| 54 | - } else if (state === "rejected") { | |
| 55 | - sse.close(); | |
| 56 | - window.location.href = "/login/fediverse/error?reason=rejected"; | |
| 57 | - } else if (state === "expired") { | |
| 58 | - sse.close(); | |
| 59 | - window.location.href = "/login/fediverse/error?reason=timeout"; | |
| 60 | - } else if (state === "consumed") { | |
| 61 | - sse.close(); | |
| 62 | - window.location.href = "/home"; | |
| 63 | - } | |
| 64 | - // state === "pending" → rien à faire, on attend le prochain poll. | |
| 65 | - }); | |
| 66 | - | |
| 67 | - sse.onerror = function() { | |
| 68 | - // Connexion perdue ; le serveur va relancer mais on | |
| 69 | - // protège l'UX si le navigateur abandonne après plusieurs | |
| 70 | - // tentatives. | |
| 71 | - sse.close(); | |
| 72 | - setTimeout(function() { | |
| 73 | - window.location.href = "/login/fediverse/error?reason=timeout"; | |
| 74 | - }, 30000); | |
| 75 | - }; | |
| 76 | - })(); | |
| 77 | - </script> | |
| 78 | -</body> | |
| 79 | -</html> |
D
crates/rustwarden-ui/templates/profile/fediverse_connected_apps.html
+0
-61
@@ -1,61 +0,0 @@
| 1 | -<!DOCTYPE html> | |
| 2 | -<html lang="{{ current_locale }}"> | |
| 3 | -<head> | |
| 4 | - <meta charset="UTF-8"> | |
| 5 | - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | - <title>{{ page_title }}</title> | |
| 7 | - <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | - <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | -</head> | |
| 10 | -<body class="min-h-screen bg-base-200 px-4 py-8"> | |
| 11 | - <main class="container mx-auto max-w-3xl"> | |
| 12 | - <header class="mb-6"> | |
| 13 | - <h1 class="text-3xl font-bold mb-2">{{ heading }}</h1> | |
| 14 | - <p class="text-base-content/70">{{ subtitle }}</p> | |
| 15 | - </header> | |
| 16 | - | |
| 17 | - {% if has_rows %} | |
| 18 | - <section class="card bg-base-100 shadow-xl mb-6"> | |
| 19 | - <div class="card-body"> | |
| 20 | - <div class="overflow-x-auto"> | |
| 21 | - <table class="table table-zebra"> | |
| 22 | - <thead> | |
| 23 | - <tr> | |
| 24 | - <th>{{ table_domain }}</th> | |
| 25 | - <th>{{ table_count }}</th> | |
| 26 | - <th>{{ table_last_seen }}</th> | |
| 27 | - </tr> | |
| 28 | - </thead> | |
| 29 | - <tbody> | |
| 30 | - {% for row in rows %} | |
| 31 | - <tr> | |
| 32 | - <td class="font-mono">{{ row.domain }}</td> | |
| 33 | - <td>{{ row.session_count }}</td> | |
| 34 | - <td class="text-base-content/70">{{ row.last_seen }}</td> | |
| 35 | - </tr> | |
| 36 | - {% endfor %} | |
| 37 | - </tbody> | |
| 38 | - </table> | |
| 39 | - </div> | |
| 40 | - <div class="alert alert-info mt-4" role="status"> | |
| 41 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 42 | - <span>{{ revoke_note }}</span> | |
| 43 | - </div> | |
| 44 | - </div> | |
| 45 | - </section> | |
| 46 | - {% else %} | |
| 47 | - <section class="card bg-base-200 shadow-inner mb-6" aria-label="empty-state"> | |
| 48 | - <div class="card-body items-center text-center py-12"> | |
| 49 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> | |
| 50 | - <h3 class="text-lg font-semibold">{{ empty_title }}</h3> | |
| 51 | - <p class="text-base-content/70 max-w-md">{{ empty_body }}</p> | |
| 52 | - </div> | |
| 53 | - </section> | |
| 54 | - {% endif %} | |
| 55 | - | |
| 56 | - <div class="flex justify-end"> | |
| 57 | - <a href="/profile" class="btn btn-ghost">{{ back_to_profile }}</a> | |
| 58 | - </div> | |
| 59 | - </main> | |
| 60 | -</body> | |
| 61 | -</html> |
A
docs/ANSSI_FEDIVERSE_COMPLIANCE.md
+620
-0
@@ -0,0 +1,620 @@
| 1 | +# Rapport de Conformité ANSSI — `rustwarden-fediverse` | |
| 2 | + | |
| 3 | +> **Date** : 2026-05-13 | |
| 4 | +> **Crate** : `rustwarden-fediverse` 0.1.0 | |
| 5 | +> **Référence** : [Guide ANSSI Rust](https://anssi-fr.github.io/rust-guide/) | |
| 6 | +> **Portée** : `crates/rustwarden-fediverse/` uniquement (35 fichiers `.rs`) | |
| 7 | +> **Méthode** : audit statique + `cargo audit` (606 deps, advisory-db 1080 advisories) + `cargo clippy --all-targets -- -W clippy::pedantic` | |
| 8 | + | |
| 9 | +## Résumé Exécutif | |
| 10 | + | |
| 11 | +### Score Global de Conformité | |
| 12 | + | |
| 13 | +- ✅ **Conforme** : 80% | |
| 14 | +- ⚠️ **Améliorations recommandées** : 15% | |
| 15 | +- ❌ **Non conforme** : 5% | |
| 16 | + | |
| 17 | +### Findings par sévérité | |
| 18 | + | |
| 19 | +| Sévérité | Nombre | | |
| 20 | +|----------|--------| | |
| 21 | +| Critical | 1 | | |
| 22 | +| High | 3 | | |
| 23 | +| Medium | 5 | | |
| 24 | +| Low | 3 | | |
| 25 | +| Info | 2 | | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## 1. Utilisation des Outils de l'Écosystème Rust | |
| 30 | + | |
| 31 | +### 1.1 Gestion des Dépendances | |
| 32 | + | |
| 33 | +#### ✅ Règle LIBS-OUTDATED — CONFORME | |
| 34 | + | |
| 35 | +- `Cargo.toml` déclare toutes les dépendances avec des versions fixées ou workspace-inherited. | |
| 36 | +- **Note** : `activitypub_federation = "=0.7.0-beta.11"` est une beta pinned exacte — à surveiller pour les mises à jour de sécurité. | |
| 37 | + | |
| 38 | +#### ✅ Règle LIBS-AUDIT — CONFORME | |
| 39 | + | |
| 40 | +- `cargo audit` (606 deps scannées, exit 0) : **0 vulnérabilité connue**. | |
| 41 | +- Dépendance `subtle = "2.6"` présente dans `Cargo.toml` — bon signe pour les comparaisons constant-time, mais **non utilisée explicitement dans le code source** (cf. section 6). | |
| 42 | + | |
| 43 | +### 1.2 Compilation et Build | |
| 44 | + | |
| 45 | +#### ❌ Règle BUILD-WARNINGS — NON CONFORME — Sévérité Low | |
| 46 | + | |
| 47 | +**Statut** : `#![deny(warnings)]` absent. | |
| 48 | + | |
| 49 | +**Fichiers concernés** : | |
| 50 | +- `crates/rustwarden-fediverse/src/lib.rs:1` | |
| 51 | +- `crates/rustwarden-fediverse/migration/src/lib.rs:1` | |
| 52 | + | |
| 53 | +Le workspace déclare `[workspace.lints.rust] missing_docs = "warn"` mais aucun `deny(warnings)` au niveau crate. Clippy pedantic produit 10 warnings non fatals. | |
| 54 | + | |
| 55 | +**Recommandation** : | |
| 56 | +```rust | |
| 57 | +// crates/rustwarden-fediverse/src/lib.rs, ligne 1 | |
| 58 | +#![deny(warnings)] | |
| 59 | +``` | |
| 60 | + | |
| 61 | +Ou, préférablement, au niveau workspace dans `Cargo.toml` : | |
| 62 | +```toml | |
| 63 | +[workspace.lints.rust] | |
| 64 | +warnings = "deny" | |
| 65 | +``` | |
| 66 | + | |
| 67 | +--- | |
| 68 | + | |
| 69 | +## 2. Gestion de la Mémoire | |
| 70 | + | |
| 71 | +### 2.1 Mémoire Non Initialisée | |
| 72 | + | |
| 73 | +#### ✅ Règle MEM-UNINIT — CONFORME | |
| 74 | +Aucune utilisation de `std::mem::uninitialized` détectée dans la crate. | |
| 75 | + | |
| 76 | +#### ✅ Règle MEM-MAYBEUNINIT — CONFORME | |
| 77 | +Aucune utilisation de `MaybeUninit` détectée. | |
| 78 | + | |
| 79 | +### 2.2 Fuites de Mémoire | |
| 80 | + | |
| 81 | +#### ✅ Avertissement MEM-FORGET — CONFORME | |
| 82 | +Aucune utilisation de `std::mem::forget` détectée. | |
| 83 | + | |
| 84 | +### 2.3 Pointeurs Bruts | |
| 85 | + | |
| 86 | +#### ✅ Règle MEM-RAW-PTR — CONFORME | |
| 87 | +Aucun pointeur brut (`*const T`, `*mut T`) détecté dans le code source ou les migrations. | |
| 88 | + | |
| 89 | +--- | |
| 90 | + | |
| 91 | +## 3. Code Unsafe | |
| 92 | + | |
| 93 | +### 3.1 Utilisation du Mot-clé `unsafe` | |
| 94 | + | |
| 95 | +#### ✅ Règle UNSAFE-JUSTIFY — CONFORME (avec petits manques) | |
| 96 | + | |
| 97 | +**Statut** : Toutes les utilisations de `unsafe` sont dans du code de test, et la plupart sont documentées avec `// SAFETY:`. | |
| 98 | + | |
| 99 | +**Utilisations détectées** (toutes dans `#[cfg(test)]`) : | |
| 100 | + | |
| 101 | +| Fichier | Lignes | Justification SAFETY | | |
| 102 | +|---------|--------|---------------------| | |
| 103 | +| `src/config.rs` | 339, 349 | ✅ Oui (`// SAFETY: tests in this module are serialised through ENV_LOCK`) | | |
| 104 | +| `src/services/actor_key_service.rs` | 175, 185 | ✅ Oui (`// SAFETY: tests run sequentially`) | | |
| 105 | +| `tests/e2e_fediverse.rs` | 321, 323, 477, 479 | ❌ **Non** — `// SAFETY:` absent | | |
| 106 | + | |
| 107 | +**Sévérité** : Low | |
| 108 | + | |
| 109 | +**Recommandation** : | |
| 110 | +```rust | |
| 111 | +// SAFETY: e2e tests run sequentially (--test-threads=1 in CI for this crate). | |
| 112 | +// The env var is restored to its previous value after use. | |
| 113 | +unsafe { std::env::set_var("ENCRYPTION_KEY", BETA_ENCRYPTION_KEY) }; | |
| 114 | +``` | |
| 115 | + | |
| 116 | +#### ✅ Règle UNSAFE-MINIMIZE — CONFORME | |
| 117 | + | |
| 118 | +- 0 occurrences dans le code de production. | |
| 119 | +- 8 occurrences dans les tests, toutes pour `std::env::set_var` / `remove_var` (unsafe depuis Rust 1.66). | |
| 120 | + | |
| 121 | +#### ✅ Recommandation UNSAFE-REVIEW — CONFORME | |
| 122 | + | |
| 123 | +Aucun `unsafe` en production. Les usages test ont un périmètre limité et sont sérialisés via `Mutex<()>`. | |
| 124 | + | |
| 125 | +--- | |
| 126 | + | |
| 127 | +## 4. Foreign Function Interface (FFI) | |
| 128 | + | |
| 129 | +#### ✅ Règle FFI-TYPES — CONFORME (N/A) | |
| 130 | + | |
| 131 | +Aucune interface FFI détectée. Pas de `extern`, `#[link]`, `CStr`, `CString`, `c_char` dans la crate. | |
| 132 | + | |
| 133 | +--- | |
| 134 | + | |
| 135 | +## 5. Gestion des Erreurs | |
| 136 | + | |
| 137 | +### 5.1 Types de Résultat | |
| 138 | + | |
| 139 | +#### ⚠️ Règle ERR-RESULT — AMÉLIORATION RECOMMANDÉE — Sévérité Medium | |
| 140 | + | |
| 141 | +**Statut** : 2 occurrences de `expect()` dans le code de production. | |
| 142 | + | |
| 143 | +| # | Fichier:Ligne | Code | Contexte | | |
| 144 | +|---|--------------|------|----------| | |
| 145 | +| 1 | `src/config.rs:30` | `.expect("valid config")` | Doctest uniquement (dans ```` ``` ignore ````). Acceptable. | | |
| 146 | +| 2 | `src/services/fediverse_signal_service.rs:272` | `.expect("reqwest client builder must not fail")` | Code de production dans `AlwaysAcceptVerifier::with_resolver()`. | | |
| 147 | + | |
| 148 | +**Finding F-ERR-1** : `src/services/fediverse_signal_service.rs:272` | |
| 149 | + | |
| 150 | +Le `reqwest::Client::builder().timeout(...).build().expect(...)` peut paniquer si la configuration TLS native échoue (certificats système manquants, runtime mal configuré). C'est dans le constructeur d'`AlwaysAcceptVerifier::with_resolver()` qui est documenté comme test/dev-only, mais le code est compilé en mode production. | |
| 151 | + | |
| 152 | +**Sévérité** : Medium (le verifier est dev-only par contrat, mais le panic est en code prod) | |
| 153 | + | |
| 154 | +**Recommandation** : | |
| 155 | +```rust | |
| 156 | +// BAD | |
| 157 | +let http = reqwest::Client::builder() | |
| 158 | + .timeout(ACTOR_FETCH_TIMEOUT) | |
| 159 | + .build() | |
| 160 | + .expect("reqwest client builder must not fail"); | |
| 161 | + | |
| 162 | +// GOOD | |
| 163 | +let http = reqwest::Client::builder() | |
| 164 | + .timeout(ACTOR_FETCH_TIMEOUT) | |
| 165 | + .build() | |
| 166 | + .map_err(|e| AppError::Internal(format!("reqwest client build failed: {e}")))?; | |
| 167 | +// (requires changing with_resolver return type to Result) | |
| 168 | +``` | |
| 169 | + | |
| 170 | +--- | |
| 171 | + | |
| 172 | +## 6. Cryptographie | |
| 173 | + | |
| 174 | +### 6.1 Génération de Clés RSA (RFC 0005) | |
| 175 | + | |
| 176 | +#### ✅ Règle CRYPTO-RNG — CONFORME | |
| 177 | + | |
| 178 | +**Fichier** : `src/services/actor_key_service.rs:47-48` | |
| 179 | + | |
| 180 | +```rust | |
| 181 | +let mut rng = OsRng; | |
| 182 | +let private_key = RsaPrivateKey::new(&mut rng, RSA_BITS)?; | |
| 183 | +``` | |
| 184 | + | |
| 185 | +- Source d'aléa : `OsRng` (CSPRNG du système). **Conforme**. | |
| 186 | +- Taille de clé : `RSA_BITS = 2048` (ligne 22). Conforme au minimum ANSSI (RSA-2048+). | |
| 187 | +- Encodage : PKCS#8 (private) / SPKI (public). Standard. | |
| 188 | +- Stockage : la clé privée est chiffrée via `CryptoService::encrypt()` (AES-256-GCM, cf. `rustwarden-core`) avant persistance. Le code refuse de générer si `ENCRYPTION_KEY` n'est pas configurée (ligne 113-116). **Conforme**. | |
| 189 | + | |
| 190 | +### 6.2 Nonce Cryptographique (RFC 0006) | |
| 191 | + | |
| 192 | +#### ✅ CONFORME | |
| 193 | + | |
| 194 | +**Fichier** : `src/services/fediverse_auth_service.rs:570-573` | |
| 195 | + | |
| 196 | +```rust | |
| 197 | +fn generate_nonce() -> String { | |
| 198 | + let mut buf = [0u8; 32]; | |
| 199 | + rand::rng().fill_bytes(&mut buf); | |
| 200 | + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf) | |
| 201 | +} | |
| 202 | +``` | |
| 203 | + | |
| 204 | +- 256 bits d'entropie via `rand::rng()` (ThreadRng, `OsRng`-backed depuis rand 0.9). **Conforme**. | |
| 205 | +- Encodage URL-safe base64 sans padding. **Conforme**. | |
| 206 | +- Test de non-répétition présent (`generate_nonce_is_unique_and_url_safe`). | |
| 207 | + | |
| 208 | +### 6.3 Signatures HTTP (RFC 0015/0016) | |
| 209 | + | |
| 210 | +#### ⚠️ CONFORME avec RÉSERVES — Sévérité High | |
| 211 | + | |
| 212 | +**Fichier** : `src/http_signatures.rs` | |
| 213 | + | |
| 214 | +**Points positifs** : | |
| 215 | +- Algorithme : `rsa-sha256` uniquement accepté (PKCS#1 v1.5 + SHA-256). Les algorithmes non supportés sont rejetés (ligne 162-166). | |
| 216 | +- Vérification via `rsa::pkcs1v15::VerifyingKey` — implémentation auditée de la crate `rsa`. | |
| 217 | +- Tests de non-régression : signature valide acceptée, signature altérée rejetée, mauvaise clé publique rejetée. | |
| 218 | + | |
| 219 | +#### Finding F-CRYPTO-1 — Absence de vérification du header `Digest` — Sévérité **High** | |
| 220 | + | |
| 221 | +**Fichier** : `src/http_signatures.rs:169` | |
| 222 | + | |
| 223 | +```rust | |
| 224 | +let _ = body; // body is hashed via the `digest` header by Mastodon when present. | |
| 225 | +``` | |
| 226 | + | |
| 227 | +Le paramètre `body` de `parse_and_verify_cavage()` est **ignoré**. La vérification de signature porte sur les headers signés (`(request-target)`, `host`, `date`) mais **pas sur le body digest**. Un attaquant qui intercepte une requête signée peut : | |
| 228 | +1. Conserver les headers signés intacts. | |
| 229 | +2. Remplacer le body JSON par un payload malveillant. | |
| 230 | +3. Le serveur vérifie la signature (qui porte sur les headers) et accepte le body falsifié. | |
| 231 | + | |
| 232 | +**Exploitabilité** : Requiert un MITM ou un accès aux logs du load balancer (les headers signés y transitent). Modérée. | |
| 233 | + | |
| 234 | +**Blast radius** : Injection d'activités AP arbitraires (Update/Delete/Revoke) sous l'identité d'un acteur légitime. | |
| 235 | + | |
| 236 | +**Remédiation** : | |
| 237 | +```rust | |
| 238 | +// GOOD: verify body digest before signature check | |
| 239 | +pub fn parse_and_verify_cavage( | |
| 240 | + signature_header: &str, | |
| 241 | + request_target: &str, | |
| 242 | + body: &[u8], | |
| 243 | + public_pem: &str, | |
| 244 | + headers: impl Fn(&str) -> Option<String>, | |
| 245 | +) -> Result<(), AppError> { | |
| 246 | + let parsed = parse_signature_header(signature_header)?; | |
| 247 | + // ...existing algorithm check... | |
| 248 | + | |
| 249 | + // Verify body digest if present in signed headers | |
| 250 | + if parsed.headers.contains(&"digest".to_string()) { | |
| 251 | + let expected_digest = headers("digest").ok_or_else(|| { | |
| 252 | + AppError::Validation("Signature includes digest but header missing".into()) | |
| 253 | + })?; | |
| 254 | + let computed = format!( | |
| 255 | + "SHA-256={}", | |
| 256 | + base64::engine::general_purpose::STANDARD.encode( | |
| 257 | + sha2::Sha256::digest(body) | |
| 258 | + ) | |
| 259 | + ); | |
| 260 | + if expected_digest != computed { | |
| 261 | + return Err(AppError::Validation("Digest mismatch".into())); | |
| 262 | + } | |
| 263 | + } | |
| 264 | + // ...rest of verification... | |
| 265 | +} | |
| 266 | +``` | |
| 267 | + | |
| 268 | +#### Finding F-CRYPTO-2 — Absence de validation temporelle du header `Date` — Sévérité **High** | |
| 269 | + | |
| 270 | +**Fichier** : `src/http_signatures.rs` (ensemble du module) | |
| 271 | + | |
| 272 | +Le header `Date` est inclus dans la chaîne signée (ligne 296-299 pour la signature sortante), mais le vérificateur entrant ne contrôle **jamais** l'écart entre `Date` et l'heure courante. Un attaquant qui capture une requête signée peut la **rejouer indéfiniment**. | |
| 273 | + | |
| 274 | +La pratique standard (Mastodon, Pleroma, Misskey) est de rejeter les signatures dont le `Date` header s'écarte de plus de 30 secondes à 5 minutes de l'heure serveur. | |
| 275 | + | |
| 276 | +**Exploitabilité** : Requiert la capture d'une requête signée valide. Modérée à élevée si les logs sont accessibles. | |
| 277 | + | |
| 278 | +**Blast radius** : Replay d'activités Delete/Update/Revoke, potentiellement destructeur. | |
| 279 | + | |
| 280 | +**Remédiation** : | |
| 281 | +```rust | |
| 282 | +// Inside HttpSigVerifier::verify() or parse_and_verify_cavage(): | |
| 283 | +if let Some(date_str) = ctx.headers.get("date").and_then(|v| v.to_str().ok()) { | |
| 284 | + let parsed = chrono::DateTime::parse_from_rfc2822(date_str) | |
| 285 | + .map_err(|_| AppError::Validation("invalid Date header".into()))?; | |
| 286 | + let skew = (Utc::now() - parsed.with_timezone(&Utc)).num_seconds().abs(); | |
| 287 | + if skew > 300 { | |
| 288 | + return Err(AppError::Validation(format!( | |
| 289 | + "Date header too far from server time ({skew}s > 300s)" | |
| 290 | + ))); | |
| 291 | + } | |
| 292 | +} | |
| 293 | +``` | |
| 294 | + | |
| 295 | +### 6.4 Comparaisons Constant-Time | |
| 296 | + | |
| 297 | +#### ⚠️ AMÉLIORATION RECOMMANDÉE — Sévérité Low | |
| 298 | + | |
| 299 | +La crate `subtle = "2.6"` est déclarée dans `Cargo.toml` mais **aucun import ou usage** de `subtle::ConstantTimeEq` n'est présent dans le code source. Les comparaisons de signatures sont effectuées par la crate `rsa` (qui utilise `subtle` en interne), donc ce point est couvert **indirectement**. Néanmoins, si des comparaisons de nonces ou tokens sont ajoutées à l'avenir, `subtle` devrait être utilisé explicitement. | |
| 300 | + | |
| 301 | +--- | |
| 302 | + | |
| 303 | +## 7. Surface d'Attaque Réseau | |
| 304 | + | |
| 305 | +### 7.1 Inbox Handler | |
| 306 | + | |
| 307 | +#### ⚠️ CONFORME avec RÉSERVES | |
| 308 | + | |
| 309 | +**Fichier** : `src/inbox.rs` | |
| 310 | + | |
| 311 | +**Points positifs** : | |
| 312 | +- Vérification de signature obligatoire (ligne 55-60 : rejet si `Signature` header absent). | |
| 313 | +- Trust policy gate RFC 0027 appliquée AVANT la vérification de signature (ligne 71-80). **Conforme**. | |
| 314 | +- Dispatch type-safe par `activity.kind` avec rejet explicite des types inconnus (ligne 100-102). | |
| 315 | +- Validation JSON structurée via `serde_json::from_slice` (ligne 62-63). | |
| 316 | + | |
| 317 | +#### Finding F-NET-1 — Absence de limite de taille du body sur `POST /inbox` — Sévérité **Medium** | |
| 318 | + | |
| 319 | +**Fichiers** : `src/inbox.rs`, `src/routes.rs`, `src/auth_routes.rs` | |
| 320 | + | |
| 321 | +Aucun `RequestBodyLimit` (tower-http) ni `axum::extract::DefaultBodyLimit` n'est appliqué sur les routes `POST /inbox` et `POST /fediverse/auth/inbox`. Un attaquant peut envoyer un body JSON de taille arbitraire, provoquant une allocation mémoire proportionnelle. | |
| 322 | + | |
| 323 | +Le fetch d'acteur distant a une limite (`MAX_ACTOR_BODY_BYTES = 1 MiB`, ligne 313), mais l'inbox n'en a pas. | |
| 324 | + | |
| 325 | +**Exploitabilité** : Remote, unauthenticated (l'inbox est public par spec AP). | |
| 326 | + | |
| 327 | +**Blast radius** : Denial of Service par épuisement mémoire. | |
| 328 | + | |
| 329 | +**Remédiation** : | |
| 330 | +```rust | |
| 331 | +// In src/routes.rs, router_with(): | |
| 332 | +use axum::extract::DefaultBodyLimit; | |
| 333 | + | |
| 334 | +Router::new() | |
| 335 | + .route("/inbox", post(inbox::handle)) | |
| 336 | + .layer(DefaultBodyLimit::max(256 * 1024)) // 256 KiB | |
| 337 | + // ... | |
| 338 | +``` | |
| 339 | + | |
| 340 | +### 7.2 Outbound Fetch (SSRF) | |
| 341 | + | |
| 342 | +#### ✅ CONFORME | |
| 343 | + | |
| 344 | +**Fichier** : `src/services/fediverse_signal_service.rs:328-367` | |
| 345 | + | |
| 346 | +La fonction `ssrf_guard()` : | |
| 347 | +- Bloque les IPs loopback (`127.0.0.0/8`, `::1`). | |
| 348 | +- Bloque les IPs privées RFC 1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`). | |
| 349 | +- Bloque les IPs link-local (`169.254.0.0/16`, `fe80::/10`). | |
| 350 | +- Bloque les IPs ULA IPv6 (`fc00::/7`). | |
| 351 | +- Bloque les IPs unspecified (`0.0.0.0`, `::`). | |
| 352 | +- Le bypass `allow_private_ips=true` n'est utilisé que par `AlwaysAcceptVerifier` (dev/test-only, documenté ligne 267). | |
| 353 | +- Timeout : 10s (`ACTOR_FETCH_TIMEOUT`). | |
| 354 | +- Body cap : 1 MiB (`MAX_ACTOR_BODY_BYTES`). | |
| 355 | +- Test de régression présent (`ssrf_guard_rejects_loopback_when_strict`). | |
| 356 | + | |
| 357 | +### 7.3 WebFinger | |
| 358 | + | |
| 359 | +#### ✅ CONFORME | |
| 360 | + | |
| 361 | +**Fichier** : `src/webfinger.rs` | |
| 362 | + | |
| 363 | +- Validation du préfixe `acct:` obligatoire (ligne 89-91). | |
| 364 | +- Validation du domaine : rejet si `domain != state.config.domain()` (ligne 35-37). | |
| 365 | +- Validation des segments vides (ligne 97-101). | |
| 366 | +- Header `Access-Control-Allow-Origin: *` correct pour WebFinger (RFC 7033 §3.2). | |
| 367 | + | |
| 368 | +### 7.4 Auth Flow — `redirect_url` Non Sanitisé | |
| 369 | + | |
| 370 | +#### Finding F-NET-2 — `redirect_url` non validé — Sévérité **Medium** | |
| 371 | + | |
| 372 | +**Fichier** : `src/auth_routes.rs:55`, `src/services/fediverse_auth_service.rs:74,142` | |
| 373 | + | |
| 374 | +Le champ `redirect_url: Option<String>` de `InitiateBody` est passé directement à la base de données sans aucune validation. Un attaquant peut injecter une URL arbitraire (`javascript:`, `data:`, ou un domaine tiers) qui sera retournée au client après authentification. | |
| 375 | + | |
| 376 | +Ce finding est **identique** à l'observation 463 du `CLAUDE.md` sur le middleware `auth_required` du core. | |
| 377 | + | |
| 378 | +**Exploitabilité** : Remote, unauthenticated (l'endpoint `initiate` est public). | |
| 379 | + | |
| 380 | +**Blast radius** : Open redirect, phishing. | |
| 381 | + | |
| 382 | +**Recommandation** : | |
| 383 | +```rust | |
| 384 | +// Validate redirect_url before persisting | |
| 385 | +fn validate_redirect_url(url: &str, allowed_host: &str) -> Result<(), AppError> { | |
| 386 | + let parsed = url::Url::parse(url) | |
| 387 | + .map_err(|_| AppError::Validation("invalid redirect URL".into()))?; | |
| 388 | + if parsed.scheme() != "https" && parsed.scheme() != "http" { | |
| 389 | + return Err(AppError::Validation("redirect URL must be http(s)".into())); | |
| 390 | + } | |
| 391 | + if parsed.host_str() != Some(allowed_host) { | |
| 392 | + return Err(AppError::Validation("redirect URL host mismatch".into())); | |
| 393 | + } | |
| 394 | + Ok(()) | |
| 395 | +} | |
| 396 | +``` | |
| 397 | + | |
| 398 | +### 7.5 Invite Routes — Autorisation | |
| 399 | + | |
| 400 | +#### Finding F-NET-3 — Routes invite sans authorisation obligatoire — Sévérité **High** | |
| 401 | + | |
| 402 | +**Fichier** : `src/invite_routes.rs:107-124` | |
| 403 | + | |
| 404 | +Le handler `invite()` accepte `claims: Option<Extension<Claims>>` (ligne 110). Quand aucun JWT n'est présent, il fallback sur `system_actor_user_id` (ligne 122-123). Cela signifie que les endpoints suivants sont accessibles **sans authentification** : | |
| 405 | + | |
| 406 | +- `POST /api/v1/teams/{team_id}/members/invite-fediverse` | |
| 407 | +- `GET /api/v1/teams/{team_id}/invites` | |
| 408 | +- `POST /api/v1/teams/{team_id}/invites/{invite_id}/revoke` | |
| 409 | +- `POST /api/v1/fediverse/invites/{invite_id}/accept` | |
| 410 | +- `POST /api/v1/fediverse/invites/{invite_id}/reject` | |
| 411 | + | |
| 412 | +Le commentaire (ligne 118-120) documente que c'est intentionnel pour le slice minimal, mais c'est un **risque de production** : un attaquant non authentifié peut inviter n'importe qui dans n'importe quelle équipe. | |
| 413 | + | |
| 414 | +**Exploitabilité** : Remote, unauthenticated. | |
| 415 | + | |
| 416 | +**Blast radius** : Élévation de privilèges (ajout d'un remote identity comme admin d'une team). | |
| 417 | + | |
| 418 | +**Remédiation** : Appliquer le middleware `auth_required` sur les routes inviter-side (`/api/v1/teams/...`) et ne garder le fallback que sur les routes IdP-side. | |
| 419 | + | |
| 420 | +### 7.6 Consent Endpoint — CSRF | |
| 421 | + | |
| 422 | +#### Finding F-NET-4 — CSRF non appliqué sur `/api/v1/fediverse/auth/consent` — Sévérité **Medium** | |
| 423 | + | |
| 424 | +**Fichier** : `src/auth_routes.rs:99,252-277` | |
| 425 | + | |
| 426 | +Le commentaire ligne 99 mentionne "CSRF-protected by the consumer middleware" mais la route elle-même **ne vérifie aucun token CSRF**. Si le consumer oublie de monter le middleware CSRF, l'endpoint est exploitable par cross-site request forgery. | |
| 427 | + | |
| 428 | +### 7.7 Placeholder JWT Secret | |
| 429 | + | |
| 430 | +#### Finding F-NET-5 — Secret JWT placeholder en code de production — Sévérité **Critical** | |
| 431 | + | |
| 432 | +**Fichier** : `src/routes.rs:107-126` | |
| 433 | + | |
| 434 | +```rust | |
| 435 | +fn placeholder_auth_config() -> AuthConfig { | |
| 436 | + AuthConfig { | |
| 437 | + jwt_secret: "0".repeat(64), | |
| 438 | + // ... | |
| 439 | + } | |
| 440 | +} | |
| 441 | +``` | |
| 442 | + | |
| 443 | +La fonction `placeholder_auth_config()` est appelée ligne 54 quand `AuthConfig::from_env()` échoue : | |
| 444 | + | |
| 445 | +```rust | |
| 446 | +let auth_flow_state = AuthFlowState { | |
| 447 | + auth_config: Arc::new(AuthConfig::from_env().unwrap_or_else(|_| placeholder_auth_config())), | |
| 448 | + // ... | |
| 449 | +}; | |
| 450 | +``` | |
| 451 | + | |
| 452 | +Si les variables d'environnement JWT ne sont pas configurées (déploiement mal configuré, container sans `.env`), le serveur démarre avec un `jwt_secret = "000...0"` (64 zéros). Tout JWT signé avec ce secret est prévisible : un attaquant peut forger des tokens valides. | |
| 453 | + | |
| 454 | +**Exploitabilité** : Remote, unauthenticated si le déploiement est mal configuré. | |
| 455 | + | |
| 456 | +**Blast radius** : Forgery de JWT arbitraires, usurpation d'identité complète. | |
| 457 | + | |
| 458 | +**Remédiation** : | |
| 459 | +```rust | |
| 460 | +// BAD: silent fallback to weak secret | |
| 461 | +AuthConfig::from_env().unwrap_or_else(|_| placeholder_auth_config()) | |
| 462 | + | |
| 463 | +// GOOD: fail loudly | |
| 464 | +AuthConfig::from_env().map_err(|e| { | |
| 465 | + tracing::error!("AuthConfig::from_env() failed: {e} — refusing to start with placeholder"); | |
| 466 | + panic!("JWT configuration is required for federation auth routes"); | |
| 467 | +})? | |
| 468 | +``` | |
| 469 | + | |
| 470 | +Ou, si le router doit rester constructible sans env vars (pour les tests), utiliser `router_with_auth()` exclusivement en production. | |
| 471 | + | |
| 472 | +--- | |
| 473 | + | |
| 474 | +## 8. SQL et Accès aux Données | |
| 475 | + | |
| 476 | +### 8.1 Code de Production | |
| 477 | + | |
| 478 | +#### ✅ CONFORME | |
| 479 | + | |
| 480 | +Tout le code de production utilise exclusivement SeaORM (`Entity::find()`, `ActiveModel::insert/update`, `Entity::delete_many()` avec `QueryFilter`). Aucun `format!()` avec du SQL. Aucune concaténation de chaîne dans des requêtes. | |
| 481 | + | |
| 482 | +### 8.2 Migrations | |
| 483 | + | |
| 484 | +#### ✅ CONFORME avec RÉSERVE — Sévérité Info | |
| 485 | + | |
| 486 | +**Fichier** : `migration/src/m20260512_000001_fediverse_protocol_schema.rs` | |
| 487 | + | |
| 488 | +- Les seeds `app_settings` utilisent `Query::insert()` avec des valeurs bound (lignes 112-133). **Conforme**. | |
| 489 | +- Les deletes utilisent `Query::delete()` avec `Expr::col(...).eq(key)` (lignes 440-444). **Conforme**. | |
| 490 | +- **Reste** 2 appels `execute_unprepared()` : | |
| 491 | + - Ligne 362 : `"CREATE INDEX fediverse_pending_invites_status_idx ON fediverse_pending_invites (status, expires_at)"` — DDL statique, pas d'injection possible. | |
| 492 | + - Ligne 373 : `"DROP INDEX IF EXISTS fediverse_pending_invites_status_idx"` — DDL statique. | |
| 493 | + | |
| 494 | +Ces usages sont acceptables car ce sont des DDL statiques sans interpolation de données utilisateur. | |
| 495 | + | |
| 496 | +### 8.3 Tests E2E | |
| 497 | + | |
| 498 | +#### ⚠️ AMÉLIORATION RECOMMANDÉE — Sévérité Info | |
| 499 | + | |
| 500 | +**Fichier** : `tests/e2e_fediverse.rs` (lignes 445, 574, 606, 687, 695, 749, 790, 817, 844, 850, 881, 945, 1031) | |
| 501 | + | |
| 502 | +Les tests e2e utilisent `format!("SELECT ... WHERE id='{challenge_id}'")` avec des `Uuid` interpolés. Ceci n'est pas un risque de sécurité (les valeurs sont des UUID générés par le test, pas des entrées utilisateur), mais c'est contraire à la règle projet "pas de raw SQL". En test e2e, c'est tolérable. | |
| 503 | + | |
| 504 | +--- | |
| 505 | + | |
| 506 | +## 9. Secrets et Configuration | |
| 507 | + | |
| 508 | +### 9.1 Secrets Hardcodés | |
| 509 | + | |
| 510 | +#### ✅ CONFORME (code de production) | |
| 511 | + | |
| 512 | +Aucun API key, mot de passe, ou token en dur dans le code de production. Les configurations crypto viennent exclusivement de variables d'environnement : | |
| 513 | + | |
| 514 | +- `ENCRYPTION_KEY` : via `CryptoService::is_available()` / `CryptoService::encrypt()`. | |
| 515 | +- `FEDIVERSE_DOMAIN` : via `FediverseConfig::from_env()`. | |
| 516 | +- `JWT_SECRET` : via `AuthConfig::from_env()` (mais cf. F-NET-5 sur le fallback). | |
| 517 | + | |
| 518 | +Les `jwt_secret` en dur dans `src/auth_routes.rs:340` et `src/services/fediverse_auth_service.rs:599` sont dans des blocs `#[cfg(test)]` uniquement. | |
| 519 | + | |
| 520 | +### 9.2 Protection des Clés Privées | |
| 521 | + | |
| 522 | +#### ✅ CONFORME | |
| 523 | + | |
| 524 | +**Fichier** : `src/services/actor_key_service.rs:94-127` | |
| 525 | + | |
| 526 | +- La clé privée RSA n'est **jamais** écrite en clair en base de données. | |
| 527 | +- `ensure_keys()` refuse de générer si `CryptoService::is_available()` retourne `false` (ligne 113-116). | |
| 528 | +- Le `ActorKeyMaterial` retourné contient la clé déchiffrée en mémoire ; elle n'est jamais logguée (`tracing::warn` dans le resolver ne logue que `error`, pas la clé). | |
| 529 | + | |
| 530 | +### 9.3 Placeholder Domain Rejects | |
| 531 | + | |
| 532 | +#### ✅ CONFORME | |
| 533 | + | |
| 534 | +**Fichier** : `src/config.rs:231-248` | |
| 535 | + | |
| 536 | +Les domaines placeholder (`example.com`, `localhost.localdomain`, `your.tld`, etc.) sont refusés. Les URLs `http://` sont refusées sauf en mode `debug_local_federation`. | |
| 537 | + | |
| 538 | +--- | |
| 539 | + | |
| 540 | +## 10. Concurrence et Parallélisme | |
| 541 | + | |
| 542 | +#### ✅ Règle CONC-SHARED — CONFORME | |
| 543 | + | |
| 544 | +- Partage via `Arc<dyn T>` pour les traits (`LocalUserResolver`, `SignatureVerifier`, `RemoteFetcher`). | |
| 545 | +- `Mutex<()>` utilisé dans les tests pour sérialiser les mutations de variables d'environnement. | |
| 546 | +- `#[allow(clippy::await_holding_lock)]` documenté dans `actor_key_service.rs:136-137` avec justification (mock DB, non-bloquant). | |
| 547 | + | |
| 548 | +--- | |
| 549 | + | |
| 550 | +## Plan d'Action Prioritaire | |
| 551 | + | |
| 552 | +### Priorité 1 — CRITIQUE (corriger avant merge) | |
| 553 | + | |
| 554 | +| # | Finding | Fichier | Action | | |
| 555 | +|---|---------|---------|--------| | |
| 556 | +| F-NET-5 | JWT secret placeholder `"000...0"` en fallback silencieux | `src/routes.rs:54,107-126` | Supprimer le fallback ou le remplacer par un panic explicite | | |
| 557 | + | |
| 558 | +### Priorité 2 — HAUTE (corriger dans la semaine) | |
| 559 | + | |
| 560 | +| # | Finding | Fichier | Action | | |
| 561 | +|---|---------|---------|--------| | |
| 562 | +| F-CRYPTO-1 | Body digest non vérifié par `parse_and_verify_cavage` | `src/http_signatures.rs:169` | Implémenter la vérification du header `Digest` | | |
| 563 | +| F-CRYPTO-2 | Header `Date` non validé temporellement (replay) | `src/http_signatures.rs` (module) | Ajouter un contrôle d'écart max 300s | | |
| 564 | +| F-NET-3 | Routes invite sans auth obligatoire | `src/invite_routes.rs:110,207-228` | Appliquer `auth_required` middleware | | |
| 565 | + | |
| 566 | +### Priorité 3 — MOYENNE (corriger dans le mois) | |
| 567 | + | |
| 568 | +| # | Finding | Fichier | Action | | |
| 569 | +|---|---------|---------|--------| | |
| 570 | +| F-NET-1 | Pas de body size limit sur inbox | `src/inbox.rs`, `src/routes.rs` | Ajouter `DefaultBodyLimit::max(256_000)` | | |
| 571 | +| F-NET-2 | `redirect_url` non sanitisé | `src/auth_routes.rs:55` | Valider scheme + host | | |
| 572 | +| F-NET-4 | CSRF non enforced sur consent | `src/auth_routes.rs:252-277` | Documenter ou enforcer | | |
| 573 | +| F-ERR-1 | `expect()` en code prod (verifier dev-only) | `src/services/fediverse_signal_service.rs:272` | Remplacer par `?` | | |
| 574 | +| — | `ammonia` déclarée mais non importée dans la crate | `Cargo.toml:42` | Vérifier l'intention | | |
| 575 | + | |
| 576 | +### Priorité 4 — BASSE (backlog) | |
| 577 | + | |
| 578 | +| # | Finding | Fichier | Action | | |
| 579 | +|---|---------|---------|--------| | |
| 580 | +| — | `#![deny(warnings)]` absent | `src/lib.rs` | Ajouter | | |
| 581 | +| — | `// SAFETY:` manquant sur 4 blocs e2e | `tests/e2e_fediverse.rs:321,323,477,479` | Documenter | | |
| 582 | +| — | `subtle` déclarée mais non utilisée directement | `Cargo.toml:35` | Retirer ou utiliser explicitement | | |
| 583 | + | |
| 584 | +--- | |
| 585 | + | |
| 586 | +## Checklist de Conformité | |
| 587 | + | |
| 588 | +### Avant chaque Commit | |
| 589 | + | |
| 590 | +- [x] `cargo clippy` ne signale pas de problèmes (0 erreur, 10 warnings pedantic cosmétiques) | |
| 591 | +- [x] `cargo audit` ne signale pas de vulnérabilités (0 CVE sur 606 deps) | |
| 592 | +- [x] Tous les tests passent | |
| 593 | +- [x] Aucun `unsafe` dans le code de production | |
| 594 | +- [ ] `#![deny(warnings)]` actif (à ajouter) | |
| 595 | +- [x] Pas de raw SQL dans le code de production | |
| 596 | +- [x] Secrets en variables d'environnement uniquement | |
| 597 | + | |
| 598 | +### Avant chaque Release | |
| 599 | + | |
| 600 | +- [ ] Body digest vérifié dans les HTTP signatures (F-CRYPTO-1) | |
| 601 | +- [ ] Replay protection via Date header (F-CRYPTO-2) | |
| 602 | +- [ ] Placeholder JWT secret supprimé (F-NET-5) | |
| 603 | +- [ ] Body size limits sur les inbox routes (F-NET-1) | |
| 604 | +- [ ] Auth middleware sur les invite routes (F-NET-3) | |
| 605 | + | |
| 606 | +--- | |
| 607 | + | |
| 608 | +## Conclusion | |
| 609 | + | |
| 610 | +La crate `rustwarden-fediverse` présente un **bon niveau de conformité** aux recommandations ANSSI pour une crate nouvellement extraite. Les fondamentaux sont solides : pas de raw SQL, pas d'`unsafe` en production, secrets en env vars, CSPRNG pour la crypto, SSRF guard sur les fetches sortants, trust policy appliquée avant tout traitement. | |
| 611 | + | |
| 612 | +Le **finding critique** est le fallback silencieux sur un JWT secret placeholder (`F-NET-5`) qui doit être corrigé avant tout merge en branche principale. Les **findings high** (vérification du digest HTTP, protection contre le replay, et routes invite sans auth) sont des surfaces d'attaque connues du protocole ActivityPub qui doivent être adressées avant une mise en production fédérée. | |
| 613 | + | |
| 614 | +Le code est globalement bien structuré, les erreurs sont propagées via `Result` + `?`, et la couverture de tests (unitaires + e2e) est significative. | |
| 615 | + | |
| 616 | +**Note importante** : aucun finding n'a été introduit par le refactor de migrations effectué le 2026-05-12 (fusion des migrations fediverse en `m20260510_000009_fediverse_interop` côté core et `m20260512_000001_fediverse_protocol_schema` côté fediverse, table de tracking dédiée `seaql_fediverse_migrations`). Tous les findings sont des dettes techniques pré-existantes de la phase d'écriture initiale de la crate, à l'exception de la migration fusionnée qui utilise correctement le DSL `Query::insert/delete` (renforcement positif). | |
| 617 | + | |
| 618 | +--- | |
| 619 | + | |
| 620 | +**Prochaine revue recommandée** : après implémentation des corrections priorité 1 et 2. |
A
docs/plans/extract-fediverse-ui-crate.md
+401
-0
@@ -0,0 +1,401 @@
| 1 | +# Plan — Extraction de l'UI fédération vers `rustwarden-fediverse-ui` | |
| 2 | + | |
| 3 | +## Context | |
| 4 | + | |
| 5 | +Aujourd'hui, ~3 000 lignes de code « UI fédération » (handlers admin AP, login fediverse, consent, SSE, invites, 11 templates Askama, ~15 clés i18n par langue) vivent dans `crates/rustwarden-ui/` derrière `#[cfg(feature = "fediverse")]`. Cela mélange deux responsabilités dans la même crate : | |
| 6 | + | |
| 7 | +- Présentation HTML cœur (login local, profil, admin, paramètres). | |
| 8 | +- Présentation HTML spécifique au protocole ActivityPub. | |
| 9 | + | |
| 10 | +**Problème** : un consommateur qui veut le protocole AP sans l'UI Rustwarden (parce qu'il a sa propre interface) doit néanmoins compiler tout l'attirail UI fédération si la feature est activée, et ne peut pas réutiliser proprement les templates de référence. | |
| 11 | + | |
| 12 | +**Outcome** : créer une crate dédiée `rustwarden-fediverse-ui` qui : | |
| 13 | +- Contient les handlers, templates, DTO, routes et clés i18n liés à l'UI fédération. | |
| 14 | +- Expose une fonction `default_routes() -> Router<DatabaseConnection>` mergeable dans Axum. | |
| 15 | +- Expose un DTO `dashboard_card()` réutilisable par n'importe quel dashboard. | |
| 16 | +- Expose ses templates de référence via `include_str!` pour les projets qui ré-implémentent leur UI. | |
| 17 | +- Permet à un consommateur d'activer **séparément** le protocole AP (`fediverse`) et l'UI de référence (`fediverse-ui`). | |
| 18 | + | |
| 19 | +Après la migration, `rustwarden-ui` n'a plus aucune connaissance de la fédération (suppression complète de la feature `fediverse`). | |
| 20 | + | |
| 21 | +--- | |
| 22 | + | |
| 23 | +## Décisions actées | |
| 24 | + | |
| 25 | +| Question | Choix | | |
| 26 | +|---|---| | |
| 27 | +| Feature flags app | Deux features distinctes : `fediverse` (protocole, headless) + `fediverse-ui` (UI optionnelle). | | |
| 28 | +| Carte dashboard | Injection via DTO : `rustwarden-fediverse-ui` expose une fonction `dashboard_card()` qui retourne un `DashboardCardDTO`. Le DTO `DashboardStats` accepte une `Vec<DashboardCardDTO>` que l'app fusionne avant rendu. Aucune référence "fediverse" dans le template `dashboard.html`. | | |
| 29 | +| Templates de référence | Activer dès cette migration : `pub mod template_examples` avec constantes `include_str!` + dossier `examples/templates/` documenté dans le README. | | |
| 30 | + | |
| 31 | +--- | |
| 32 | + | |
| 33 | +## Architecture cible | |
| 34 | + | |
| 35 | +``` | |
| 36 | +crates/ | |
| 37 | +├── rustwarden-core/ (auth, services, modèles, i18n) | |
| 38 | +├── rustwarden-fediverse/ (protocole AP : actor, inbox, signatures, services) | |
| 39 | +├── rustwarden-ui/ (UI cœur : login local, profil, admin sans fédération) | |
| 40 | +└── rustwarden-fediverse-ui/ (UI fédération de référence : handlers + templates + dto) ← NOUVEAU | |
| 41 | +``` | |
| 42 | + | |
| 43 | +DAG de dépendances (sans cycle) : | |
| 44 | + | |
| 45 | +``` | |
| 46 | +rustwarden-core ◄── rustwarden-fediverse ◄── rustwarden-fediverse-ui | |
| 47 | + ▲ │ | |
| 48 | + └────────── rustwarden-ui ◄────────────────────┘ (rustwarden-fediverse-ui dépend optionnellement de rustwarden-ui pour template_helpers/macro impl_template_response — voir étape 3) | |
| 49 | +``` | |
| 50 | + | |
| 51 | +Pattern Axum reproduit à l'identique de `rustwarden-fediverse` : | |
| 52 | +- Pas de generics `<S: FromRef>` — `Router<DatabaseConnection>` + `Extension<...>` pour le state, conformément au pattern existant ([crates/rustwarden-fediverse/src/routes.rs:30-71](crates/rustwarden-fediverse/src/routes.rs#L30-L71)). | |
| 53 | +- Une fonction `pub fn default_routes() -> Router<DatabaseConnection>` cohérente avec `rustwarden_ui::default_routes()` ([crates/rustwarden-ui/src/lib.rs:22-24](crates/rustwarden-ui/src/lib.rs#L22-L24)). | |
| 54 | +- Câblage côté `app/src/main.rs` via `.merge_routes(...)` du builder `RustwardenApp` existant ([app/src/main.rs:18-41](app/src/main.rs#L18-L41)). | |
| 55 | + | |
| 56 | +--- | |
| 57 | + | |
| 58 | +## Étapes | |
| 59 | + | |
| 60 | +### 1. Créer la nouvelle crate `crates/rustwarden-fediverse-ui/` | |
| 61 | + | |
| 62 | +**Fichier** : `crates/rustwarden-fediverse-ui/Cargo.toml` | |
| 63 | + | |
| 64 | +```toml | |
| 65 | +[package] | |
| 66 | +name = "rustwarden-fediverse-ui" | |
| 67 | +version = "0.1.0" | |
| 68 | +edition.workspace = true | |
| 69 | +rust-version.workspace = true | |
| 70 | +description = "Reference HTML UI for Rustwarden ActivityPub federation — handlers, templates, dashboard card" | |
| 71 | + | |
| 72 | +[dependencies] | |
| 73 | +rustwarden-core = { path = "../rustwarden-core" } | |
| 74 | +rustwarden-fediverse = { path = "../rustwarden-fediverse" } | |
| 75 | +rustwarden-ui = { path = "../rustwarden-ui" } # pour template_helpers + macro impl_template_response | |
| 76 | +axum = { version = "0.8", features = ["macros"] } | |
| 77 | +tokio = { version = "1", features = ["full"] } | |
| 78 | +async-trait = "0.1" | |
| 79 | +futures = "0.3" | |
| 80 | +serde = { version = "1.0", features = ["derive"] } | |
| 81 | +serde_json = "1.0" | |
| 82 | +askama = "0.15" | |
| 83 | +tracing = "0.1" | |
| 84 | +sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } | |
| 85 | +uuid = { version = "1.0", features = ["v4", "serde"] } | |
| 86 | +chrono = { version = "0.4", features = ["serde"] } | |
| 87 | +tower-sessions = "0.14" | |
| 88 | +axum-extra = { version = "0.12", features = ["cookie"] } | |
| 89 | +validator = { version = "0.20", features = ["derive"] } | |
| 90 | +time = { version = "0.3", features = ["macros", "serde"] } | |
| 91 | + | |
| 92 | +[lints] | |
| 93 | +workspace = true | |
| 94 | +``` | |
| 95 | + | |
| 96 | +**Note** : la dépendance `rustwarden-ui` est nécessaire pour réutiliser `template_helpers` (`t(...)` i18n) et la macro `impl_template_response!`. Si on souhaitait une indépendance totale, ces helpers devraient remonter dans `rustwarden-core` ; ce serait un refactor séparé hors scope. | |
| 97 | + | |
| 98 | +**Workspace** : ajouter `"crates/rustwarden-fediverse-ui"` aux `members` dans le `Cargo.toml` racine. | |
| 99 | + | |
| 100 | +--- | |
| 101 | + | |
| 102 | +### 2. Migrer les fichiers (`git mv` à conserver pour l'historique) | |
| 103 | + | |
| 104 | +| Source (`crates/rustwarden-ui/...`) | Destination (`crates/rustwarden-fediverse-ui/...`) | | |
| 105 | +|---|---| | |
| 106 | +| `src/handlers/admin_fediverse.rs` (1 695 lignes) | `src/handlers/admin.rs` | | |
| 107 | +| `src/handlers/fediverse_user_auth.rs` (1 053 lignes, inclut tests) | `src/handlers/user_auth.rs` | | |
| 108 | +| `templates/fediverse/consent_screen.html` | `templates/consent_screen.html` | | |
| 109 | +| `templates/fediverse/error_unauthorized.html` | `templates/error_unauthorized.html` | | |
| 110 | +| `templates/fediverse/invite_landing.html` | `templates/invite_landing.html` | | |
| 111 | +| `templates/fediverse/login_form.html` | `templates/login_form.html` | | |
| 112 | +| `templates/fediverse/login_waiting.html` | `templates/login_waiting.html` | | |
| 113 | +| `templates/admin/fediverse/nodeinfo.html` | `templates/admin/nodeinfo.html` | | |
| 114 | +| `templates/admin/fediverse/queue.html` | `templates/admin/queue.html` | | |
| 115 | +| `templates/admin/fediverse/sessions_remote.html` | `templates/admin/sessions_remote.html` | | |
| 116 | +| `templates/admin/fediverse/settings.html` | `templates/admin/settings.html` | | |
| 117 | +| `templates/admin/fediverse/team_invites.html` | `templates/admin/team_invites.html` | | |
| 118 | +| `templates/admin/fediverse/trust_list.html` | `templates/admin/trust_list.html` | | |
| 119 | +| `templates/profile/fediverse_connected_apps.html` | `templates/connected_apps.html` | | |
| 120 | + | |
| 121 | +**Total** : 2 handlers + 14 templates. | |
| 122 | + | |
| 123 | +Mettre à jour les chemins `#[template(path = "...")]` dans les structs Askama des 2 handlers pour pointer vers les nouvelles localisations. | |
| 124 | + | |
| 125 | +--- | |
| 126 | + | |
| 127 | +### 3. Créer le `lib.rs` de la nouvelle crate | |
| 128 | + | |
| 129 | +**Fichier** : `crates/rustwarden-fediverse-ui/src/lib.rs` | |
| 130 | + | |
| 131 | +```rust | |
| 132 | +//! Reference HTML UI for Rustwarden ActivityPub federation. | |
| 133 | + | |
| 134 | +pub mod handlers; | |
| 135 | +pub mod routes; | |
| 136 | +pub mod dashboard; | |
| 137 | +pub mod template_examples; | |
| 138 | + | |
| 139 | +pub use routes::default_routes; | |
| 140 | +pub use dashboard::dashboard_card; | |
| 141 | +``` | |
| 142 | + | |
| 143 | +**Fichier** : `crates/rustwarden-fediverse-ui/src/handlers/mod.rs` | |
| 144 | + | |
| 145 | +```rust | |
| 146 | +pub mod admin; | |
| 147 | +pub mod user_auth; | |
| 148 | +``` | |
| 149 | + | |
| 150 | +**Fichier** : `crates/rustwarden-fediverse-ui/src/routes.rs` | |
| 151 | + | |
| 152 | +Recopier exactement les 3 blocs `cfg(feature = "fediverse")` actuellement dans [crates/rustwarden-ui/src/routes.rs:63-87, 141-165, 196-250+](crates/rustwarden-ui/src/routes.rs#L63), mais sans la directive `cfg`. Exposer : | |
| 153 | + | |
| 154 | +```rust | |
| 155 | +pub fn default_routes() -> axum::Router<sea_orm::DatabaseConnection> { | |
| 156 | + use axum::routing::{get, post}; | |
| 157 | + use crate::handlers::{admin, user_auth}; | |
| 158 | + axum::Router::new() | |
| 159 | + // Routes publiques (login fediverse) | |
| 160 | + .route("/login/fediverse/error", get(user_auth::error)) | |
| 161 | + .route("/login/fediverse", get(user_auth::login_form).post(user_auth::login_initiate)) | |
| 162 | + .route("/login/fediverse/waiting/{challenge_id}", get(user_auth::waiting)) | |
| 163 | + .route("/login/fediverse/sse/{challenge_id}", get(user_auth::sse_status)) | |
| 164 | + // Routes authentifiées (consent, invite, connected apps) | |
| 165 | + .route("/fediverse/consent/{challenge_id}", get(user_auth::consent_form).post(user_auth::consent_submit)) | |
| 166 | + .route("/profile/connected_apps", get(user_auth::connected_apps)) | |
| 167 | + .route("/fediverse/invite/{invite_id}", get(user_auth::invite_landing_form).post(user_auth::invite_landing_submit)) | |
| 168 | + // Routes admin | |
| 169 | + .route("/admin/fediverse/settings", get(admin::get_settings).post(admin::update_settings)) | |
| 170 | + .route("/admin/fediverse/trust_list", get(admin::get_trust_list)) | |
| 171 | + .route("/admin/fediverse/trust_list/add", post(admin::add_trust_entry)) | |
| 172 | + .route("/admin/fediverse/trust_list/delete", post(admin::delete_trust_entry)) | |
| 173 | + .route("/admin/fediverse/queue", get(admin::get_queue)) | |
| 174 | + .route("/admin/fediverse/sessions_remote", get(admin::get_sessions_remote)) | |
| 175 | + .route("/admin/fediverse/sessions_remote/revoke", post(admin::revoke_remote_session)) | |
| 176 | + .route("/admin/fediverse/sessions_remote/kill_domain", post(admin::kill_domain)) | |
| 177 | + .route("/admin/fediverse/nodeinfo", get(admin::get_nodeinfo)) | |
| 178 | + .route("/admin/fediverse/invites/{team_id}", get(admin::get_team_invites)) | |
| 179 | + .route("/admin/fediverse/invites/{team_id}/add", post(admin::create_team_invite)) | |
| 180 | + .route("/admin/fediverse/invites/{team_id}/revoke", post(admin::revoke_team_invite)) | |
| 181 | +} | |
| 182 | +``` | |
| 183 | + | |
| 184 | +**Fichier** : `crates/rustwarden-fediverse-ui/src/dashboard.rs` | |
| 185 | + | |
| 186 | +```rust | |
| 187 | +use rustwarden_ui::dto::DashboardCardDTO; // type à créer dans rustwarden-ui (étape 4) | |
| 188 | + | |
| 189 | +/// Returns the federation card to inject into the admin dashboard. | |
| 190 | +pub fn dashboard_card(locale: &str /* ou Locale */) -> DashboardCardDTO { | |
| 191 | + DashboardCardDTO { | |
| 192 | + title_key: "fediverse_card_title".into(), | |
| 193 | + desc_key: "fediverse_card_desc".into(), | |
| 194 | + href: "/admin/fediverse/settings".into(), | |
| 195 | + icon: "globe-alt".into(), // ou autre identifiant heroicon/daisyui | |
| 196 | + } | |
| 197 | +} | |
| 198 | +``` | |
| 199 | + | |
| 200 | +**Fichier** : `crates/rustwarden-fediverse-ui/src/template_examples.rs` | |
| 201 | + | |
| 202 | +```rust | |
| 203 | +//! Reference template strings exposed for projects re-implementing their own UI. | |
| 204 | +//! | |
| 205 | +//! Each constant contains the exact Askama source of a federation HTML template, | |
| 206 | +//! suitable for adaptation to other rendering engines (Tera, Maud, Leptos…). | |
| 207 | + | |
| 208 | +pub const CONSENT_SCREEN: &str = include_str!("../templates/consent_screen.html"); | |
| 209 | +pub const ERROR_UNAUTHORIZED: &str = include_str!("../templates/error_unauthorized.html"); | |
| 210 | +pub const INVITE_LANDING: &str = include_str!("../templates/invite_landing.html"); | |
| 211 | +pub const LOGIN_FORM: &str = include_str!("../templates/login_form.html"); | |
| 212 | +pub const LOGIN_WAITING: &str = include_str!("../templates/login_waiting.html"); | |
| 213 | +pub const ADMIN_NODEINFO: &str = include_str!("../templates/admin/nodeinfo.html"); | |
| 214 | +pub const ADMIN_QUEUE: &str = include_str!("../templates/admin/queue.html"); | |
| 215 | +pub const ADMIN_SESSIONS_REMOTE: &str = include_str!("../templates/admin/sessions_remote.html"); | |
| 216 | +pub const ADMIN_SETTINGS: &str = include_str!("../templates/admin/settings.html"); | |
| 217 | +pub const ADMIN_TEAM_INVITES: &str = include_str!("../templates/admin/team_invites.html"); | |
| 218 | +pub const ADMIN_TRUST_LIST: &str = include_str!("../templates/admin/trust_list.html"); | |
| 219 | +pub const CONNECTED_APPS: &str = include_str!("../templates/connected_apps.html"); | |
| 220 | +``` | |
| 221 | + | |
| 222 | +Créer aussi un dossier `examples/templates/` avec un `README.md` qui explique comment copier les templates et les adapter. | |
| 223 | + | |
| 224 | +--- | |
| 225 | + | |
| 226 | +### 4. Créer le DTO `DashboardCardDTO` dans `rustwarden-ui` et adapter le dashboard | |
| 227 | + | |
| 228 | +**Fichier** : `crates/rustwarden-ui/src/dto/dashboard_card.rs` (nouveau) | |
| 229 | + | |
| 230 | +```rust | |
| 231 | +use serde::Serialize; | |
| 232 | + | |
| 233 | +#[derive(Clone, Serialize)] | |
| 234 | +pub struct DashboardCardDTO { | |
| 235 | + pub title_key: String, | |
| 236 | + pub desc_key: String, | |
| 237 | + pub href: String, | |
| 238 | + pub icon: String, | |
| 239 | +} | |
| 240 | +``` | |
| 241 | + | |
| 242 | +L'exporter depuis `crates/rustwarden-ui/src/dto/mod.rs`. | |
| 243 | + | |
| 244 | +**Fichier** : `crates/rustwarden-ui/src/handlers/dashboard_handler.rs` | |
| 245 | + | |
| 246 | +Modifier la signature pour accepter une `Vec<DashboardCardDTO>` extraite via `Extension<Vec<DashboardCardDTO>>` (injectée par l'app au démarrage). Si l'extension est absente, valeur par défaut `vec![]`. | |
| 247 | + | |
| 248 | +**Fichier** : `crates/rustwarden-ui/templates/admin/dashboard.html` | |
| 249 | + | |
| 250 | +Retirer le bloc HTML actuel de la carte fediverse. Ajouter à la place une boucle Askama : | |
| 251 | + | |
| 252 | +```html | |
| 253 | +{% for card in extra_cards %} | |
| 254 | + <a href="{{ card.href }}" class="card …"> | |
| 255 | + <h3>{{ t(card.title_key) }}</h3> | |
| 256 | + <p>{{ t(card.desc_key) }}</p> | |
| 257 | + </a> | |
| 258 | +{% endfor %} | |
| 259 | +``` | |
| 260 | + | |
| 261 | +--- | |
| 262 | + | |
| 263 | +### 5. Nettoyer `rustwarden-ui` | |
| 264 | + | |
| 265 | +Dans **`crates/rustwarden-ui/Cargo.toml`** : | |
| 266 | +- Supprimer la feature `[features] fediverse = [...]` complète. | |
| 267 | +- Retirer les deps optionnelles `futures` et `rustwarden-fediverse`. | |
| 268 | + | |
| 269 | +Dans **`crates/rustwarden-ui/src/handlers/mod.rs`** : | |
| 270 | +- Supprimer les lignes 8-14 (`pub mod admin_fediverse;` et `pub mod fediverse_user_auth;`). | |
| 271 | + | |
| 272 | +Dans **`crates/rustwarden-ui/src/routes.rs`** : | |
| 273 | +- Supprimer les 3 blocs `#[cfg(feature = "fediverse")]` aux lignes 63-87, 141-165, 196-250+. | |
| 274 | + | |
| 275 | +Dans **`locales/*.yml`** (en, fr, de, es, it, pt) : | |
| 276 | +- Déplacer toutes les clés `fediverse_*` et le sous-arbre `fediverse:` vers un nouveau fichier dédié OU les laisser dans `locales/` (le service i18n est centralisé dans `rustwarden-core` et lit tout `locales/`, donc le déplacement physique n'est pas requis fonctionnellement). **Décision** : laisser les clés dans `locales/` mais documenter qu'elles sont consommées par `rustwarden-fediverse-ui`. Pas de duplication, pas de risque. | |
| 277 | + | |
| 278 | +--- | |
| 279 | + | |
| 280 | +### 6. Adapter `app/` | |
| 281 | + | |
| 282 | +**Fichier** : `app/Cargo.toml` | |
| 283 | + | |
| 284 | +```toml | |
| 285 | +[features] | |
| 286 | +default = [] | |
| 287 | +# Protocole AP headless seul. | |
| 288 | +fediverse = ["dep:rustwarden-fediverse"] | |
| 289 | +# UI HTML de référence pour la fédération (active aussi `fediverse`). | |
| 290 | +fediverse-ui = ["fediverse", "dep:rustwarden-fediverse-ui"] | |
| 291 | + | |
| 292 | +[dependencies] | |
| 293 | +rustwarden-fediverse = { path = "../crates/rustwarden-fediverse", optional = true } | |
| 294 | +rustwarden-fediverse-ui = { path = "../crates/rustwarden-fediverse-ui", optional = true } | |
| 295 | +# … reste inchangé, mais retirer `rustwarden-ui/fediverse` du tableau de la feature `fediverse` | |
| 296 | +``` | |
| 297 | + | |
| 298 | +**Fichier** : `app/src/main.rs` | |
| 299 | + | |
| 300 | +Adapter le bloc actuel `#[cfg(feature = "fediverse")]` ([app/src/main.rs:18-41](app/src/main.rs#L18-L41)) : | |
| 301 | + | |
| 302 | +```rust | |
| 303 | +#[cfg(feature = "fediverse")] | |
| 304 | +let builder = { | |
| 305 | + let db = sea_orm::Database::connect(&db_url).await?; | |
| 306 | + let fediverse_config = rustwarden_fediverse::FediverseConfig::from_env()?; | |
| 307 | + let fediverse_router = rustwarden_fediverse::routes::router(fediverse_config, db.clone()); | |
| 308 | + builder | |
| 309 | + .database(db) | |
| 310 | + .register_protected_prefix("/fediverse/consent") | |
| 311 | + .register_protected_prefix("/fediverse/invite") | |
| 312 | + .register_revocation_checker(checker) | |
| 313 | + .merge_routes(fediverse_router) | |
| 314 | +}; | |
| 315 | + | |
| 316 | +#[cfg(feature = "fediverse-ui")] | |
| 317 | +let builder = { | |
| 318 | + let fed_ui_router = rustwarden_fediverse_ui::default_routes(); | |
| 319 | + let fed_card = rustwarden_fediverse_ui::dashboard_card(""); // locale résolue par middleware | |
| 320 | + builder | |
| 321 | + .merge_routes(fed_ui_router) | |
| 322 | + .add_dashboard_card(fed_card) // nouvelle méthode du builder, voir étape 7 | |
| 323 | +}; | |
| 324 | +``` | |
| 325 | + | |
| 326 | +--- | |
| 327 | + | |
| 328 | +### 7. Ajouter `add_dashboard_card` au builder `RustwardenApp` | |
| 329 | + | |
| 330 | +Dans `crates/rustwarden-core/` (ou là où vit `RustwardenApp` / `RustwardenBuilder`), ajouter une méthode : | |
| 331 | + | |
| 332 | +```rust | |
| 333 | +pub fn add_dashboard_card(mut self, card: DashboardCardDTO) -> Self { | |
| 334 | + self.dashboard_cards.push(card); | |
| 335 | + self | |
| 336 | +} | |
| 337 | +``` | |
| 338 | + | |
| 339 | +Et au moment de construire le router, injecter `Extension(dashboard_cards.clone())` sur la route `/admin/dashboard`. | |
| 340 | + | |
| 341 | +--- | |
| 342 | + | |
| 343 | +### 8. Vérification (en zone autorisée par l'utilisateur — read-only ici, exécution post-approbation) | |
| 344 | + | |
| 345 | +1. **Compilation** : | |
| 346 | + - `cargo build --workspace` (sans features) → `rustwarden-fediverse-ui` doit ne PAS être compilée. | |
| 347 | + - `cargo build --workspace --features fediverse` → fediverse compile, fediverse-ui non. | |
| 348 | + - `cargo build --workspace --features fediverse-ui` → tout compile. | |
| 349 | +2. **Lints** : | |
| 350 | + - `cargo clippy --workspace --all-features -- -D warnings` (objectif : zéro warning, conforme constitution). | |
| 351 | +3. **Tests** : | |
| 352 | + - `cargo test --workspace --all-features` — vérifier que les tests `#[cfg(test)]` du handler `user_auth.rs` (anciens tests de `fediverse_user_auth.rs`, lignes 982-1053) passent toujours. | |
| 353 | + - `cargo test -p rustwarden-fediverse-ui` ciblé. | |
| 354 | + - `cargo test -p rustwarden-ui` (sans la feature fediverse, doit passer). | |
| 355 | +4. **Tests E2E testbed fediverse** : | |
| 356 | + - `tests/fediverse-testbed/` — vérifier que les routes admin/login fediverse répondent toujours (le testbed actuel teste surtout le protocole, mais les routes UI sont câblées via app). | |
| 357 | +5. **Vérification routes** : démarrer l'app avec `--features fediverse-ui`, requêter `GET /admin/fediverse/settings` et `GET /login/fediverse` → réponse 200 HTML. | |
| 358 | +6. **Vérification dashboard card** : démarrer l'app avec/sans `fediverse-ui`, vérifier que la carte n'apparaît qu'avec la feature. | |
| 359 | +7. **Documentation** : mettre à jour `CLAUDE.md` (section Architecture du workspace) et `docs/` pour refléter la nouvelle crate. | |
| 360 | + | |
| 361 | +--- | |
| 362 | + | |
| 363 | +## Fichiers critiques touchés (récapitulatif) | |
| 364 | + | |
| 365 | +| Fichier | Action | | |
| 366 | +|---|---| | |
| 367 | +| `Cargo.toml` (racine) | Ajouter `crates/rustwarden-fediverse-ui` aux members | | |
| 368 | +| `crates/rustwarden-fediverse-ui/**/*` | Création complète de la crate (Cargo.toml, lib.rs, routes.rs, handlers/, templates/, dashboard.rs, template_examples.rs, examples/) | | |
| 369 | +| `crates/rustwarden-ui/Cargo.toml` | Suppression feature `fediverse` + deps optionnelles | | |
| 370 | +| `crates/rustwarden-ui/src/handlers/mod.rs` | Suppression lignes 8-14 | | |
| 371 | +| `crates/rustwarden-ui/src/handlers/admin_fediverse.rs` | `git mv` → `crates/rustwarden-fediverse-ui/src/handlers/admin.rs` | | |
| 372 | +| `crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs` | `git mv` → `crates/rustwarden-fediverse-ui/src/handlers/user_auth.rs` | | |
| 373 | +| `crates/rustwarden-ui/src/routes.rs` | Suppression des 3 blocs cfg(fediverse) | | |
| 374 | +| `crates/rustwarden-ui/src/handlers/dashboard_handler.rs` | Accepter `Extension<Vec<DashboardCardDTO>>` | | |
| 375 | +| `crates/rustwarden-ui/src/dto/dashboard_card.rs` | Nouveau DTO `DashboardCardDTO` | | |
| 376 | +| `crates/rustwarden-ui/src/dto/mod.rs` | Réexporter `DashboardCardDTO` | | |
| 377 | +| `crates/rustwarden-ui/templates/admin/dashboard.html` | Retrait carte fediverse, ajout boucle `{% for card in extra_cards %}` | | |
| 378 | +| `crates/rustwarden-ui/templates/fediverse/*.html` | `git mv` (5 fichiers) | | |
| 379 | +| `crates/rustwarden-ui/templates/admin/fediverse/*.html` | `git mv` (6 fichiers) | | |
| 380 | +| `crates/rustwarden-ui/templates/profile/fediverse_connected_apps.html` | `git mv` | | |
| 381 | +| `crates/rustwarden-core/src/<builder>.rs` | Ajouter `add_dashboard_card()` | | |
| 382 | +| `app/Cargo.toml` | Séparation features `fediverse` / `fediverse-ui` | | |
| 383 | +| `app/src/main.rs` | Ajout bloc `cfg(feature = "fediverse-ui")` qui merge routes + injecte card | | |
| 384 | +| `CLAUDE.md` | Mise à jour section workspace | | |
| 385 | + | |
| 386 | +--- | |
| 387 | + | |
| 388 | +## Ce qui n'est PAS fait dans cette migration (hors scope) | |
| 389 | + | |
| 390 | +- Refactor de `template_helpers` / `impl_template_response!` pour les remonter dans `rustwarden-core` (la nouvelle crate dépend de `rustwarden-ui` pour ces helpers — acceptable mais à revisiter si on veut une indépendance totale). | |
| 391 | +- Migration des clés i18n vers un fichier dédié `locales/fediverse.yml` (le service i18n centralisé n'en a pas besoin fonctionnellement). | |
| 392 | +- Suppression de `tower-http` features inutilisées dans `rustwarden-ui` (signalée dans CLAUDE.md mais hors scope). | |
| 393 | +- Internationalisation du `dashboard_card()` côté locale (la version actuelle passe les clés i18n en string ; la résolution `t()` se fait côté template via `template_helpers`). | |
| 394 | + | |
| 395 | +--- | |
| 396 | + | |
| 397 | +## Estimation | |
| 398 | + | |
| 399 | +- **Effort** : ~1 journée de travail (déplacement + recâblage + DTO dashboard + tests). | |
| 400 | +- **Risque** : faible. Aucune dépendance externe sur les handlers/dto fediverse de `rustwarden-ui` détectée (vérifié dans `app/`, `crates/rustwarden-fediverse/`, `tools/`). Pas de cycle créé. Pattern Axum identique à `rustwarden-fediverse` existant. | |
| 401 | +- **Bénéfice** : un consommateur headless économise ~3 000 lignes de compilation, peut implémenter sa propre UI fédération en réutilisant les `template_examples`, et la crate `rustwarden-ui` recouvre une responsabilité unique. |
GitRust