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.