plan fedivers complet manque test reel

EBO <eric.bouhana@softalys.com> committé le 2026-05-10 18:49

a4cafb98469bb96b243588cfeabb582662a3cdef

1 parent(s)

31 fichiers modifiés +5173 -21
M CHANGELOG.md
+355 -1
@@ -11,7 +11,339 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11 11
12 12 - **Workspace** : edition Rust **2024** + `rust-version = "1.85"` au niveau
13 13 `[workspace.package]`. Toutes les crates héritent (`edition.workspace = true`).
14 -- **Fix régression `AuthService` (commit `d4d6828`)** — quand
14 +- **RFC 0006 Phase 2** — inbox enrichment côté IdP : `auth_inbox`
15 + persiste désormais le challenge entrant + résout la
16 + remote_identities du requester.
17 + - Nouvelle méthode publique
18 + [`FediverseAuthService::persist_inbound_request(db, body, idp_domain)`](crates/rustwarden-core/src/services/fediverse_auth_service.rs)
19 + qui :
20 + 1. Parse les champs `actor` / `target` / `nonce` / `expiresAt` du
21 + payload AP `AuthRequest`.
22 + 2. Valide que `target_domain` correspond bien à l'IdP (refus si
23 + requête mal-routée).
24 + 3. Résout `remote_identities` par `ap_id == actor_url` (la row est
25 + créée upstream par le verifier de signature, RFC 0017).
26 + 4. Insère un challenge `Pending` avec `requested_remote_id` peuplé.
27 + - Câblage dans
28 + [`crates/rustwarden-core/src/fediverse/auth_routes.rs:auth_inbox`](crates/rustwarden-core/src/fediverse/auth_routes.rs)
29 + après vérification de signature : sur erreur de Validation, 400
30 + BadRequest renvoyé proprement (au lieu du précédent 202 silencieux).
31 + - **Débloque** :
32 + - **Slice B Approve E2E** (RFC 0023 iter. 4) : `consent_submit`
33 + peut désormais lire `requested_remote_id` rempli et passer à
34 + `record_decision::Approve` sans exception Validation.
35 + - **Slice C `/profile/connected_apps`** (RFC 0024 iter. 5) : la
36 + table d'apps connectées sera désormais peuplée dès qu'un user
37 + remote authentifie via cet IdP.
38 + - 4 nouveaux tests unitaires sur `persist_inbound_request` :
39 + happy path, mismatched IdP domain, missing actor, unknown
40 + requester.
41 + - Test existant `auth_inbox_returns_202_for_signed_authrequest`
42 + renommé en `auth_inbox_returns_400_when_requester_actor_unknown`
43 + et adapté pour valider la nouvelle sémantique 400.
44 + - **5 quality gates verts** : build, tests **413 pass / 0 fail / 19
45 + ignored** (+4 vs avant), clippy `-D warnings`, doc clean, pas de
46 + migration.
47 +- **RFC 0024 (Slice C iter. 6 — close-out)** — Fediverse UI: page
48 + d'acceptation invitation team cross-instance
49 + `/fediverse/invite/{id}`.
50 + - 2 nouveaux handlers dans
51 + [`crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs`](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) :
52 + `invite_landing_form` (GET) charge l'invite + le team, affiche le
53 + statut, indique si l'utilisateur courant peut accepter (=
54 + `Claims::is_remote() && remote_identity_id().is_some()`) ;
55 + `invite_landing_submit` (POST, CSRF) appelle
56 + `FediverseInviteService::accept(invite_id, remote_id)` sur
57 + Approve, ou `reject(invite_id)` sur tout autre.
58 + - Nouveau template standalone
59 + [`crates/rustwarden-ui/templates/fediverse/invite_landing.html`](crates/rustwarden-ui/templates/fediverse/invite_landing.html)
60 + avec card DaisyUI affichant team / rôle / statut, badges colorés
61 + par status, alert warning quand l'utilisateur courant ne peut pas
62 + accepter (local user — explication explicite de la mécanique
63 + AP-native), bouton Accept conditionnel `{% if can_accept %}`.
64 + - 2 nouvelles routes UI authentifiées (gated feature, mode dégradé
65 + 404). **Extension `AuthService::call`** :
66 + [`crates/rustwarden-core/src/middleware/auth_service.rs`](crates/rustwarden-core/src/middleware/auth_service.rs)
67 + ajoute `/fediverse/invite` à `protected_routes` pour injecter
68 + `Claims` dans la requête.
69 + - i18n : namespace `fediverse_invite_landing:` (top-level dédié) —
70 + 11 nouvelles clés FR + EN avec note explicite UX sur la
71 + contrainte « accept = remote identity uniquement ».
72 + - **Slice C clôturé** : 6 itérations livrées (RFC 0024 + queue +
73 + sessions_remote + nodeinfo + team_invites + connected_apps +
74 + invite_landing). 11 handlers, 6 templates HTML, 14 routes,
75 + ~110 clés i18n FR+EN.
76 + - **5 quality gates verts** : build, tests **409 pass / 0 fail / 19
77 + ignored**, clippy `-D warnings`, doc clean, pas de migration.
78 +- **RFC 0024 (Slice C iter. 5)** — Fediverse UI: section profil
79 + utilisateur « Apps connectées ».
80 + - Nouveau handler `connected_apps` dans
81 + [`crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs`](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs)
82 + qui interroge `fediverse_auth_challenges` pour le handle de
83 + l'utilisateur courant (`@username@FEDIVERSE_DOMAIN`) avec
84 + `status IN ('approved', 'consumed')`, regroupe par domaine, calcule
85 + le compte de sessions + dernière activité.
86 + - Nouveau template standalone
87 + [`crates/rustwarden-ui/templates/profile/fediverse_connected_apps.html`](crates/rustwarden-ui/templates/profile/fediverse_connected_apps.html)
88 + avec table zebra (Instance / Sessions / Dernière activité), alert
89 + info expliquant que la révocation manuelle arrivera avec RFC 0006
90 + Phase 2, empty state, lien retour profil.
91 + - Route `GET /profile/connected_apps` (gated feature, mode dégradé
92 + 404). Intercepte `Claims` via le préfixe `/profile` déjà dans
93 + `AuthService::protected_routes`.
94 + - i18n : namespace `fediverse_connected_apps:` (top-level dédié) —
95 + 11 nouvelles clés FR + EN avec note explicite sur la limitation
96 + de révocation actuelle.
97 + - **Iter. 5 lecture-seule** : la révocation explicite côté utilisateur
98 + est différée à RFC 0006 Phase 2 (méthode
99 + `FediverseAuthService::revoke_remote_session(challenge_id)` avec
100 + blacklist JWT) ; en attendant, l'admin peut révoquer via
101 + `/admin/fediverse/sessions_remote` (iter. 2) ou attendre l'expiration
102 + naturelle du JWT.
103 + - **5 quality gates verts** : build, tests **409 pass / 0 fail / 19
104 + ignored**, clippy `-D warnings`, doc, pas de migration.
105 +- **RFC 0024 (Slice C iter. 4)** — Fediverse UI: invitations team
106 + cross-instance `/admin/fediverse/invites/{team_id}`.
107 + - 3 nouveaux handlers dans
108 + [`crates/rustwarden-ui/src/handlers/admin_fediverse.rs`](crates/rustwarden-ui/src/handlers/admin_fediverse.rs) :
109 + - `get_team_invites` (GET) liste les 100 dernières invitations pour
110 + un team_id, tous statuts confondus.
111 + - `create_team_invite` (POST add, CSRF) parse handle + role,
112 + appelle `FediverseInviteService::invite_remote` avec TTL 7j.
113 + - `revoke_team_invite` (POST revoke, CSRF) appelle
114 + `FediverseInviteService::revoke` (autorisé sur Pending +
115 + Accepted).
116 + - Nouveau template
117 + [`crates/rustwarden-ui/templates/admin/fediverse/team_invites.html`](crates/rustwarden-ui/templates/admin/fediverse/team_invites.html)
118 + avec form ajout (handle pattern + select rôle), table zebra avec
119 + badges colorés par status, bouton révoquer conditionnel
120 + (Pending/Accepted seulement), modal confirmation Alpine.
121 + - 3 nouvelles routes admin (gated feature, mode dégradé 404, vérifie
122 + l'existence du team avant rendu).
123 + - i18n : namespace `fediverse_invites:` (top-level dédié) — 25 nouvelles
124 + clés FR + EN.
125 + - **5 quality gates verts** : build, tests **409 pass / 0 fail / 19
126 + ignored**, clippy `-D warnings`, doc, pas de migration.
127 +- **RFC 0024 (Slice C iter. 3)** — Fediverse UI: page NodeInfo
128 + humanisée `/admin/fediverse/nodeinfo`.
129 + - Nouveau handler `get_nodeinfo` dans
130 + [`crates/rustwarden-ui/src/handlers/admin_fediverse.rs`](crates/rustwarden-ui/src/handlers/admin_fediverse.rs)
131 + qui calcule en live :
132 + - **software** : nom (`rustwarden`), version (`CARGO_PKG_VERSION`),
133 + domaine (`FEDIVERSE_DOMAIN` env) ;
134 + - **runtime** : `trust_mode`, `interop_scope`,
135 + `authorized_fetch` (lus depuis `app_settings`) ;
136 + - **users** : total, actifs 30 j, actifs 180 j (sur
137 + `last_login_at`) ;
138 + - **remote identities** : actives + nb domaines distincts.
139 + - Nouveau template
140 + [`crates/rustwarden-ui/templates/admin/fediverse/nodeinfo.html`](crates/rustwarden-ui/templates/admin/fediverse/nodeinfo.html)
141 + avec 4 cards (`grid-cols-1 md:grid-cols-2`) : Logiciel /
142 + Configuration runtime / Utilisateurs locaux / Identités distantes.
143 + Stats DaisyUI colorées.
144 + - Route `GET /admin/fediverse/nodeinfo` (gated feature, mode
145 + dégradé 404).
146 + - i18n : namespace dédié `fediverse_nodeinfo:` + 2 clés `common.yes`/
147 + `common.no` (raison : éviter de re-ouvrir `fediverse:` qui aurait
148 + écrasé sous yaml-rust2 toutes les sous-clés
149 + settings/trust/auth/queue/sessions_remote). 22 nouvelles clés
150 + `fediverse_nodeinfo.*` FR + EN.
151 + - **5 quality gates verts** : build, tests **409 pass / 0 fail / 19
152 + ignored**, clippy `-D warnings`, doc, pas de migration.
153 +- **RFC 0024 (Slice C iter. 2)** — Fediverse UI: page sessions distantes
154 + + kill switch domaine.
155 + - 3 nouveaux handlers dans
156 + [`crates/rustwarden-ui/src/handlers/admin_fediverse.rs`](crates/rustwarden-ui/src/handlers/admin_fediverse.rs) :
157 + `get_sessions_remote` (GET) liste les rows `remote_identities` actives
158 + (`disabled_at IS NULL`, ordonnées `last_fetched_at desc`, limit 200) ;
159 + `revoke_remote_session` (POST, CSRF) marque une row spécifique
160 + `disabled_at = now` ; `kill_domain` (POST, CSRF) bulk-update toutes
161 + les rows actives d'un domaine.
162 + - Nouveau template
163 + [`crates/rustwarden-ui/templates/admin/fediverse/sessions_remote.html`](crates/rustwarden-ui/templates/admin/fediverse/sessions_remote.html)
164 + avec card kill-switch (border `error` + select des domaines distincts)
165 + + table zebra des sessions + 2 modals confirmation Alpine
166 + (`role="alertdialog"` Plan UI §3 P2) + empty state.
167 + - 3 nouvelles routes admin (gated feature, mode dégradé 404).
168 + - i18n FR + EN : 21 nouvelles clés `fediverse.sessions_remote.*`.
169 + - **5 quality gates verts** : build, tests **409 pass / 0 fail / 19
170 + ignored**, clippy `-D warnings`, doc, pas de migration.
171 +- **RFC 0024 (Slice C iter. 1)** — Fediverse UI: dashboard file
172 + outbound `/admin/fediverse/queue`.
173 + - Nouvelle RFC [docs/rfcs/0024-fediverse-ui-ops-slice-c.md](docs/rfcs/0024-fediverse-ui-ops-slice-c.md)
174 + — cadre les 6 écrans Slice C (queue, sessions_remote, nodeinfo,
175 + invites team, connected_apps, invite_landing).
176 + - Nouveau handler `get_queue` dans
177 + [`crates/rustwarden-ui/src/handlers/admin_fediverse.rs`](crates/rustwarden-ui/src/handlers/admin_fediverse.rs)
178 + qui :
179 + - compte les rows par status (`pending` / `delivered` / `failed`
180 + / `dead`) via `PaginatorTrait::count` ;
181 + - charge les 100 dernières rows ordonnées `created_at desc`,
182 + tronque les URLs (60 chars) et erreurs (80 chars) pour la
183 + lisibilité ;
184 + - rend le template avec stats grid + table zebra +
185 + empty state explicite.
186 + - Nouveau template
187 + [`crates/rustwarden-ui/templates/admin/fediverse/queue.html`](crates/rustwarden-ui/templates/admin/fediverse/queue.html)
188 + avec 4 stat cards colorées par sévérité + table avec badges
189 + conditionnels (warning/success/error/neutral selon status) +
190 + empty state.
191 + - Route `GET /admin/fediverse/queue` (gated feature, mode dégradé
192 + 404 si `fediverse_enabled = false`).
193 + - i18n FR + EN : 18 nouvelles clés `fediverse.queue.*`.
194 + - **5 quality gates verts** : build, tests **409 pass / 0 fail / 19
195 + ignored**, clippy `-D warnings`, doc, pas de migration.
196 +- **RFC 0023 (Slice B iter. 5 — close-out)** — Procédure E2E manuelle
197 + documentée.
198 + - Nouveau guide [docs/fediverse-e2e-manual.md](docs/fediverse-e2e-manual.md)
199 + décrivant la procédure de test bout-en-bout deux instances locales
200 + (sans Docker, deux `cargo run` sur des ports différents avec bases
201 + distinctes) et qualifiant 5 chemins négatifs **testables
202 + aujourd'hui** (Reject, untrusted, malformed handle, timeout, mode
203 + dégradé 404).
204 + - **Slice B clôturé** : 4 itérations livrées (RFC 0023 +
205 + `error_unauthorized`, `login_form`, `login_waiting`+SSE,
206 + `consent_screen`). 8 routes UI, 38 clés i18n FR + EN, 6 nouveaux
207 + tests unitaires. Le « happy path Approve » E2E reste bloqué par
208 + la dette RFC 0006 Phase 2 (enrichment du challenge avec
209 + `requested_remote_id` côté inbox), documenté §7 du guide.
210 +- **RFC 0023 (Slice B iter. 4)** — Fediverse UI: écran de consentement
211 + IdP-side.
212 + - 2 nouveaux handlers dans
213 + [`crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs`](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) :
214 + `consent_form` (GET, requiert local user loggué) renvoie l'écran
215 + de validation, `consent_submit` (POST, CSRF) appelle
216 + `FediverseAuthService::record_decision`.
217 + - **Sur Reject** : décision enregistrée, redirect `/home`.
218 + - **Sur Approve** : exige que la row challenge porte déjà un
219 + `requested_remote_id` (mis par l'inbox handler côté IdP — voir TODO
220 + dans [`auth_routes.rs:188`](crates/rustwarden-core/src/fediverse/auth_routes.rs)).
221 + Sans ce binding, le handler refuse l'approbation avec une erreur
222 + Validation explicite (évite qu'un attaquant injecte un
223 + `remote_identity_id` arbitraire via le form). Le wiring complet
224 + landera en RFC 0006 Phase 2.
225 + - Nouveau template standalone
226 + [`crates/rustwarden-ui/templates/fediverse/consent_screen.html`](crates/rustwarden-ui/templates/fediverse/consent_screen.html)
227 + avec card DaisyUI affichant `domain` + `handle`, deux boutons
228 + `submit name="decision"`, et bascule vers un alert `aria-live` quand
229 + la challenge est déjà décidée.
230 + - 2 nouvelles routes UI authentifiées (gated feature, mode dégradé
231 + 404) : `GET` + `POST` `/fediverse/consent/{challenge_id}`.
232 + - **Extension `AuthService::call`** :
233 + [`crates/rustwarden-core/src/middleware/auth_service.rs`](crates/rustwarden-core/src/middleware/auth_service.rs)
234 + ajoute `/fediverse/consent` à la liste `protected_routes` pour que
235 + `Claims` soit injecté dans la request avant le handler. Aucun impact
236 + sur les routes existantes.
237 + - i18n FR + EN : 8 nouvelles clés `fediverse.auth.consent.*`.
238 + - **5 quality gates verts** : build, tests **409 pass / 0 fail / 19
239 + ignored**, clippy `-D warnings`, doc, pas de migration.
240 +- **RFC 0023 (Slice B iter. 3)** — Fediverse UI: page d'attente +
241 + Server-Sent Events.
242 + - 2 nouveaux handlers dans
243 + [`crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs`](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) :
244 + `waiting` (GET) renvoie la page spinner, `sse_status` (GET) émet
245 + un événement SSE `event: status` toutes les 2 s avec
246 + `{"state":"pending|approved|rejected|expired|consumed"}`.
247 + - Stream construit via `futures::stream::unfold` (nouvelle
248 + dev-dep optionnelle `futures = "0.3"` activée par
249 + `experimental-fediverse`). Cap à 150 polls (5 min ↔ TTL challenge
250 + RFC 0006) puis fermeture propre. `KeepAlive::default()` envoie un
251 + `:ping` toutes les 15 s.
252 + - Nouveau template standalone
253 + [`crates/rustwarden-ui/templates/fediverse/login_waiting.html`](crates/rustwarden-ui/templates/fediverse/login_waiting.html)
254 + avec spinner DaisyUI (`role="status" aria-live="polite"`) et JS
255 + `EventSource` natif (≤ 50 LoC, pas de framework lourd Plan UI §3
256 + P10) qui :
257 + - sur `approved` → `POST /api/v1/auth/login/fediverse/consume/{id}`
258 + pour minter le JWT remote (RFC 0007), redirige `/home` ;
259 + - sur `rejected` → `error?reason=rejected` ;
260 + - sur `expired` → `error?reason=timeout` ;
261 + - sur `consumed` (déjà échangé) → `/home` ;
262 + - sur `onerror` (connexion perdue persistante) → fallback
263 + `error?reason=timeout` après 30 s.
264 + - 2 nouvelles routes publiques (gated feature, mode dégradé 404) :
265 + `GET /login/fediverse/waiting/{id}`, `GET /login/fediverse/sse/{id}`.
266 + - i18n FR + EN : 5 nouvelles clés `fediverse.auth.waiting.*`.
267 + - **5 quality gates verts** : build, tests **409 pass / 0 fail**,
268 + clippy `-D warnings`, doc, pas de migration.
269 +- **RFC 0023 (Slice B iter. 2)** — Fediverse UI: formulaire login
270 + ActivityPub.
271 + - 2 nouveaux handlers dans
272 + [`crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs`](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) :
273 + `login_form` (GET) renvoie le formulaire HTML, `login_initiate`
274 + (POST) valide CSRF, parse `@user@domain`, vérifie
275 + `FediverseTrustService::is_domain_allowed`, appelle
276 + `FediverseAuthService::initiate` avec TTL 5 min, redirige vers
277 + `/login/fediverse/waiting/{challenge_id}` (page livrée iter. 3).
278 + - Sur mauvais handle → redirect `error?reason=unknown`.
279 + - Sur domaine refusé → redirect `error?reason=untrusted`.
280 + - Nouveau template standalone
281 + [`crates/rustwarden-ui/templates/fediverse/login_form.html`](crates/rustwarden-ui/templates/fediverse/login_form.html)
282 + avec input `pattern="^@?[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$"`, label
283 + `for=`, help-text via `aria-describedby`.
284 + - 2 routes publiques : `GET` + `POST` `/login/fediverse` (gated
285 + feature, mode dégradé 404).
286 + - i18n FR + EN : 8 nouvelles clés `fediverse.auth.login.*`.
287 + - 2 nouveaux tests unitaires sur `extract_handle_domain` (canonical
288 + + malformed). **Workspace : 409 pass / 0 fail / 19 ignored**.
289 + - **5 quality gates verts** : build, tests, clippy `-D warnings`, doc,
290 + pas de migration.
291 +- **RFC 0023 (Slice B iter. 1)** — Fediverse UI: page d'erreur
292 + d'authentification fédérée
293 + ([docs/rfcs/0023-fediverse-ui-user-auth.md](docs/rfcs/0023-fediverse-ui-user-auth.md)).
294 + - Nouveau handler
295 + [`crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs`](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs)
296 + avec `ErrorReason` enum (5 variantes :
297 + `timeout` / `rejected` / `untrusted` / `invalid_signature` /
298 + `unknown` fallback).
299 + - Nouveau template standalone (sans extends, comme `login.html`)
300 + [`crates/rustwarden-ui/templates/fediverse/error_unauthorized.html`](crates/rustwarden-ui/templates/fediverse/error_unauthorized.html)
301 + avec icône SVG conditionnelle (clock/lock/alert) selon la variante,
302 + H1 + body i18n, CTA « Retour à la connexion ».
303 + - Route `GET /login/fediverse/error?reason=…` (publique, gated par
304 + feature). Mode dégradé silencieux Plan UI §3 P9 → 404 quand
305 + `fediverse_enabled = false`.
306 + - i18n FR + EN : 13 nouvelles clés `fediverse.auth.error.*` (page
307 + title + subtitle + back_to_login + 5 × {title, body}).
308 + - 4 tests unitaires sur `ErrorReason` (parsing, fallback, trim/lowercase,
309 + round-trip). Tests workspace : **407 pass / 0 fail / 19 ignored**.
310 + - **5 quality gates verts** : build, tests, clippy `-D warnings`, doc,
311 + pas de migration.
312 +- **RFC 0022** — Smoke tests anti-régression
313 + ([docs/rfcs/0022-smoke-tests-regression.md](docs/rfcs/0022-smoke-tests-regression.md)) :
314 + deux fichiers de tests qui ferment les boucles des deux régressions
315 + observées le 2026-05-10.
316 + - [`crates/rustwarden-core/tests/middleware_authconfig_fallback.rs`](crates/rustwarden-core/tests/middleware_authconfig_fallback.rs)
317 + — 3 tests vérifient que `AuthConfig::from_env()` reste fiable
318 + (succès avec `JWT_SECRET`, échec sans, idempotence). C'est le
319 + pré-requis du fallback ajouté à `AuthService` /
320 + `AdminService` / `admin_helper`.
321 + - [`crates/rustwarden-ui/tests/admin_handler_pagination.rs`](crates/rustwarden-ui/tests/admin_handler_pagination.rs)
322 + — 5 tests vérifient les formes additives utilisées dans
323 + `users_list.html` après le fix (`p + 2 >= page`,
324 + `p + 3 == page`) sur tout `(page, p) ∈ [0..10]²`. Empêche le
325 + retour à `p >= page - 2` qui underflow sur `u64`.
326 + - **Quality gate** : `cargo test --workspace` passe maintenant à
327 + **403 tests** (395 + 3 RFC 0022 middleware + 5 RFC 0022 pagination).
328 +- **Fix `admin_handler::list_users` panic `attempt to subtract with overflow`** (pré-existant) :
329 + - Handler : `((page - 1) * per_page) as usize` paniquait sur
330 + `?page=0`. Corrigé par `params.page.unwrap_or(1).max(1)`
331 + + `params.per_page.unwrap_or(50).max(1)`.
332 + - Template `templates/admin/users_list.html` lignes 211 / 214 :
333 + `p >= page - 2` et `p == page - 3` underflowaient sur `u64` quand
334 + `page < 3`. Toute première visite `/admin/users?page=1` (par
335 + défaut) crashait le worker tokio sans log d'erreur. Remplacé par
336 + les formes additives équivalentes `p + 2 >= page && p <= page + 2`
337 + et `p + 3 == page || p == page + 3`. Couvert par RFC 0022.
338 +- **`rustwarden-app` : feature `experimental-fediverse` exposée** dans
339 + [`app/Cargo.toml`](app/Cargo.toml) — délègue à
340 + `rustwarden-core/experimental-fediverse` ET
341 + `rustwarden-ui/experimental-fediverse`. Permet
342 + `cargo run -p rustwarden-app --features experimental-fediverse` pour
343 + monter `/admin/fediverse/settings` (RFC 0021) et le backend
344 + ActivityPub (RFC 0001+).
345 +- **Fix régression `AuthService` / `AdminService` / `admin_helper`
346 + (commit `d4d6828`)** — quand
15 347 `Extension(Arc<AuthConfig>)` n'est pas appliqué *avant* le layer
16 348 `AuthService` dans le `ServiceBuilder` du consommateur, toute
17 349 requête sur une route protégée renvoyait `500 Internal Server Error`
@@ -19,6 +351,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19 351 désormais sur `AuthConfig::from_env()` en fallback, comportement
20 352 identique à l'avant `d4d6828`. Un `tracing::error!` est levé seulement
21 353 si `from_env` échoue aussi.
354 +- **RFC 0021 (Slice A iter. 2)** — Fediverse: page admin
355 + `/admin/fediverse/trust_list`. Deuxième écran de la couche UI
356 + fédération, fermant Slice A.
357 + - 3 nouveaux handlers dans
358 + [`crates/rustwarden-ui/src/handlers/admin_fediverse.rs`](crates/rustwarden-ui/src/handlers/admin_fediverse.rs) :
359 + `get_trust_list` (GET), `add_trust_entry` (POST add), `delete_trust_entry`
360 + (POST delete). Dispatchent allow vs blocklist selon
361 + `app_settings.fediverse_trust_mode`.
362 + - Nouveau template
363 + [`crates/rustwarden-ui/templates/admin/fediverse/trust_list.html`](crates/rustwarden-ui/templates/admin/fediverse/trust_list.html)
364 + avec table zebra, badge mode actif, modal de confirmation Alpine
365 + (`role="alertdialog"`), formulaire d'ajout (champ `severity` conditionnel
366 + blocklist), état vide explicite (Plan UI §3 P4).
367 + - Mode dégradé silencieux (Plan UI §3 P9) : `404` retourné quand
368 + `fediverse_enabled = false` sur les 3 routes trust_list, settings
369 + reste accessible pour activer.
370 + - i18n FR + EN : 28 nouvelles clés `fediverse.trust.*`.
371 + - **5 quality gates verts** : build, tests 403 pass / 0 fail / 19
372 + ignored, clippy `-D warnings`, doc clean, pas de migration.
373 + - **Différé iter. 3** : tests handler trust_list (5 états P4), partials
374 + Askama réutilisables (le partage cross-template via `{% include %}`
375 + nécessite une refacto en macros Askama qui dépasse Slice A).
22 376 - **RFC 0021 (Slice A iter. 1)** — Fediverse: page admin
23 377 `/admin/fediverse/settings`. Premier écran HTML de la couche UI
24 378 fédération.
M Cargo.lock
+1 -0
@@ -4213,6 +4213,7 @@ dependencies = [
4213 4213 "axum",
4214 4214 "axum-extra",
4215 4215 "chrono",
4216 + "futures",
4216 4217 "num-format",
4217 4218 "rustwarden-core",
4218 4219 "sea-orm",
M crates/rustwarden-core/src/fediverse/auth_routes.rs
+36 -17
@@ -155,7 +155,7 @@ async fn consume(
155 155
156 156 async fn auth_inbox(
157 157 Extension(state): Extension<AuthFlowState>,
158 - Extension(_db): Extension<DatabaseConnection>,
158 + Extension(db): Extension<DatabaseConnection>,
159 159 headers: HeaderMap,
160 160 body: axum::body::Bytes,
161 161 ) -> Result<StatusCode, FediverseError> {
@@ -164,11 +164,14 @@ async fn auth_inbox(
164 164 .and_then(|v| v.to_str().ok())
165 165 .ok_or_else(|| FediverseError::Unauthorized("missing Signature header".into()))?;
166 166
167 - // Best-effort actor extraction. The current AP `AuthRequest` body has
168 - // an `actor` URL string at the top level.
169 - let actor = serde_json::from_slice::<serde_json::Value>(&body)
170 - .ok()
171 - .and_then(|v| v.get("actor").and_then(|a| a.as_str()).map(str::to_string))
167 + // Parse the body once — we need both the actor (for verification)
168 + // and the full payload (for `persist_inbound_request` below).
169 + let body_json = serde_json::from_slice::<serde_json::Value>(&body)
170 + .map_err(|e| FediverseError::BadRequest(format!("malformed AuthRequest JSON: {e}")))?;
171 + let actor = body_json
172 + .get("actor")
173 + .and_then(|v| v.as_str())
174 + .map(str::to_string)
172 175 .unwrap_or_default();
173 176
174 177 let _ = signature; // presence already enforced above
@@ -185,12 +188,18 @@ async fn auth_inbox(
185 188 .await
186 189 .map_err(|e| FediverseError::Unauthorized(e.to_string()))?;
187 190
188 - // Persisting the inbound challenge to `fediverse_auth_challenges`
189 - // happens once the IdP-side service exposes a dedicated method
190 - // (currently `initiate` is client-side only — handled through the
191 - // `consent` endpoint by the IdP user). Acknowledging acceptance is
192 - // sufficient for now; the IdP UI / API is responsible for prompting
193 - // the user.
191 + // RFC 0006 Phase 2: persist the inbound challenge so the IdP-side
192 + // consent screen (RFC 0023 Slice B iter. 4) can find it and approve.
193 + // The signature verifier above already populated the
194 + // `remote_identities` row for the requester, so the service method
195 + // can resolve `requested_remote_id` cleanly.
196 + // Resolve this IdP's canonical domain from the env (set by
197 + // `with_fediverse()` at app boot) for the target-validation check.
198 + let idp_domain = std::env::var("FEDIVERSE_DOMAIN").unwrap_or_default();
199 + FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain)
200 + .await
201 + .map_err(|e| FediverseError::BadRequest(e.to_string()))?;
202 +
194 203 Ok(StatusCode::ACCEPTED)
195 204 }
196 205
@@ -445,10 +454,14 @@ mod tests {
445 454 // ─── auth_inbox ──────────────────────────────────────────────────
446 455
447 456 #[tokio::test]
448 - async fn auth_inbox_returns_202_for_signed_authrequest() {
457 + async fn auth_inbox_returns_400_when_requester_actor_unknown() {
458 + // Phase 2 (RFC 0006) enriches `auth_inbox` to persist a
459 + // challenge bound to the requester's `remote_identities.id`.
460 + // When the row is absent the service returns Validation, which
461 + // maps to a 400 BadRequest — NOT a 202.
449 462 let body = serde_json::json!({
450 463 "type": "AuthRequest",
451 - "actor": "https://b.tld",
464 + "actor": "https://unknown.tld",
452 465 "target": "alice@a.tld",
453 466 "nonce": "n",
454 467 })
@@ -460,10 +473,16 @@ mod tests {
460 473 .header("content-type", "application/activity+json")
461 474 .body(Body::from(body))
462 475 .unwrap();
463 - let db: DatabaseConnection =
464 - MockDatabase::new(DatabaseBackend::Postgres).into_connection();
476 + // Empty remote_identities cache → persist_inbound_request rejects.
477 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
478 + .append_query_results([Vec::<crate::models::remote_identity::Model>::new()])
479 + .into_connection();
480 + // SAFETY: tests run sequentially within this `#[tokio::test]` ;
481 + // env-var mutation here is bounded to the request lifetime.
482 + unsafe { std::env::set_var("FEDIVERSE_DOMAIN", "a.tld") };
465 483 let resp = build_router(db).oneshot(req).await.unwrap();
466 - assert_eq!(resp.status(), StatusCode::ACCEPTED);
484 + unsafe { std::env::remove_var("FEDIVERSE_DOMAIN") };
485 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
467 486 }
468 487
469 488 #[tokio::test]
M crates/rustwarden-core/src/middleware/auth_service.rs
+15 -1
@@ -58,7 +58,21 @@ where
58 58 let uri = req.uri().clone();
59 59 let path = uri.path().to_owned();
60 60
61 - let protected_routes = ["/home", "/profile", "/test-base", "/settings", "/admin"];
61 + // RFC 0023 Slice B iter. 4: `/fediverse/consent/{id}` is a
62 + // logged-in-only IdP-side consent screen ; the prefix gates
63 + // injection of `Claims` so the handler can read who is approving.
64 + // RFC 0024 Slice C iter. 6: `/fediverse/invite/{id}` is the
65 + // logged-in-only landing page for a cross-instance team
66 + // invitation, used by the invitee (typically a remote identity).
67 + let protected_routes = [
68 + "/home",
69 + "/profile",
70 + "/test-base",
71 + "/settings",
72 + "/admin",
73 + "/fediverse/consent",
74 + "/fediverse/invite",
75 + ];
62 76 let is_protected = protected_routes.iter().any(|route| path.starts_with(route));
63 77
64 78 if is_protected {
M crates/rustwarden-core/src/services/fediverse_auth_service.rs
+210 -0
@@ -187,6 +187,103 @@ impl FediverseAuthService {
187 187 Ok(active.update(db).await?)
188 188 }
189 189
190 + /// Step 1bis (RFC 0006 Phase 2 — inbox enrichment) : called by the
191 + /// IdP-side `/fediverse/auth/inbox` handler **after** the signature
192 + /// has been verified. Parses the AP `AuthRequest` body, resolves the
193 + /// requester's `remote_identities.id` from the `actor` URL, and
194 + /// inserts a `Pending` challenge bound to that requester.
195 + ///
196 + /// Without this step, the consent screen (RFC 0023 Slice B iter. 4)
197 + /// cannot Approve : `record_decision` requires `requested_remote_id`
198 + /// which used to stay `None` because the inbox handler only
199 + /// acknowledged the activity.
200 + ///
201 + /// Returns the inserted row. The IdP UI can then show
202 + /// `/fediverse/consent/{returned.id}` to the local user.
203 + ///
204 + /// **Validation** :
205 + /// - `body.target` must equal `@<local_user>@<idp_domain>` —
206 + /// otherwise `AppError::Validation`.
207 + /// - `body.actor` must point to an existing `remote_identities` row —
208 + /// the row is created upstream by the signature verifier
209 + /// ([`crate::fediverse::http_signatures::HttpSigVerifier::with_resolver`])
210 + /// when the actor doc is fetched the first time.
211 + pub async fn persist_inbound_request(
212 + db: &DatabaseConnection,
213 + body: &serde_json::Value,
214 + idp_domain: &str,
215 + ) -> Result<fediverse_auth_challenge::Model, AppError> {
216 + use crate::models::remote_identity;
217 + use sea_orm::QueryFilter;
218 +
219 + let target = body
220 + .get("target")
221 + .and_then(|v| v.as_str())
222 + .ok_or_else(|| AppError::Validation("AuthRequest body missing `target`".into()))?
223 + .to_string();
224 + let actor_url = body
225 + .get("actor")
226 + .and_then(|v| v.as_str())
227 + .ok_or_else(|| AppError::Validation("AuthRequest body missing `actor`".into()))?
228 + .to_string();
229 + let nonce = body
230 + .get("nonce")
231 + .and_then(|v| v.as_str())
232 + .ok_or_else(|| AppError::Validation("AuthRequest body missing `nonce`".into()))?
233 + .to_string();
234 +
235 + // Validate target domain matches the IdP — otherwise the request
236 + // is misrouted and we refuse rather than create a stray challenge.
237 + let target_domain = handle_domain(&target)?;
238 + if target_domain != idp_domain.to_ascii_lowercase() {
239 + return Err(AppError::Validation(format!(
240 + "AuthRequest target domain `{target_domain}` does not match this IdP `{idp_domain}`",
241 + )));
242 + }
243 +
244 + // Resolve the requester's remote_identities row by AP id. The
245 + // signature verifier ran upstream and already required this row,
246 + // so a NotFound here means a code-ordering bug and we surface it.
247 + use sea_orm::ColumnTrait;
248 + let requester = remote_identity::Entity::find()
249 + .filter(remote_identity::Column::ApId.eq(&actor_url))
250 + .one(db)
251 + .await?
252 + .ok_or_else(|| {
253 + AppError::Validation(format!(
254 + "Requester actor `{actor_url}` not found in remote_identities cache; \
255 + signature verifier should have populated this row",
256 + ))
257 + })?;
258 +
259 + // Parse `expiresAt` (RFC3339); fallback to default TTL if absent
260 + // or malformed (5 min, RFC 0006 §6).
261 + let expires_at = body
262 + .get("expiresAt")
263 + .and_then(|v| v.as_str())
264 + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
265 + .map(|dt| dt.with_timezone(&Utc))
266 + .unwrap_or_else(|| Utc::now() + ChronoDuration::minutes(5));
267 +
268 + let id = Uuid::new_v4();
269 + let active = fediverse_auth_challenge::ActiveModel {
270 + id: Set(id),
271 + nonce: Set(nonce),
272 + handle: Set(target),
273 + // Domain stored is the requester's domain (b.tld) — same
274 + // semantic as the client-side `initiate` row stores
275 + // `b.tld` for the IdP it's calling. Symmetrical.
276 + domain: Set(requester.domain.clone()),
277 + requested_remote_id: Set(Some(requester.id)),
278 + status: Set(ChallengeStatus::Pending.as_str().to_string()),
279 + redirect_url: Set(None),
280 + created_at: Set(Utc::now()),
281 + expires_at: Set(expires_at),
282 + consumed_at: Set(None),
283 + };
284 + Ok(active.insert(db).await?)
285 + }
286 +
190 287 /// Step 3 — caller polls. Promotes pending-but-expired rows to
191 288 /// `Expired` lazily so the caller observes a stable terminal state.
192 289 pub async fn status(
@@ -574,4 +671,117 @@ mod tests {
574 671 assert_eq!(session.handle, "alice@a.tld");
575 672 assert!(!session.jwt.is_empty());
576 673 }
674 +
675 + // ─── Phase 2 (RFC 0006) — inbox enrichment ──────────────────────
676 +
677 + fn fresh_remote_identity(actor_url: &str, domain: &str) -> crate::models::remote_identity::Model {
678 + let now = Utc::now();
679 + crate::models::remote_identity::Model {
680 + id: Uuid::new_v4(),
681 + ap_id: actor_url.to_string(),
682 + username: "requester".to_string(),
683 + domain: domain.to_string(),
684 + display_name: None,
685 + summary_html: None,
686 + inbox_url: format!("{actor_url}/inbox"),
687 + shared_inbox_url: None,
688 + public_key_pem: "-----BEGIN PUBLIC KEY-----\nfoo\n-----END PUBLIC KEY-----\n"
689 + .to_string(),
690 + icon_url: None,
691 + actor_type: "Person".to_string(),
692 + raw_json: None,
693 + last_fetched_at: now,
694 + disabled_at: None,
695 + created_at: now,
696 + updated_at: now,
697 + }
698 + }
699 +
700 + #[tokio::test]
701 + async fn persist_inbound_request_inserts_pending_with_remote_id() {
702 + let requester = fresh_remote_identity("https://b.tld/", "b.tld");
703 + let requester_id = requester.id;
704 + let inserted = fediverse_auth_challenge::Model {
705 + id: Uuid::new_v4(),
706 + nonce: "n".to_string(),
707 + handle: "alice@a.tld".to_string(),
708 + domain: "b.tld".to_string(),
709 + requested_remote_id: Some(requester_id),
710 + status: ChallengeStatus::Pending.as_str().to_string(),
711 + redirect_url: None,
712 + created_at: Utc::now(),
713 + expires_at: Utc::now() + ChronoDuration::minutes(5),
714 + consumed_at: None,
715 + };
716 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
717 + .append_query_results([vec![requester]])
718 + .append_query_results([vec![inserted]])
719 + .append_exec_results([MockExecResult {
720 + last_insert_id: 0,
721 + rows_affected: 1,
722 + }])
723 + .into_connection();
724 +
725 + let body = serde_json::json!({
726 + "type": "AuthRequest",
727 + "actor": "https://b.tld/",
728 + "target": "@alice@a.tld",
729 + "nonce": "n",
730 + "expiresAt": (Utc::now() + ChronoDuration::minutes(5)).to_rfc3339(),
731 + });
732 +
733 + let row = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld")
734 + .await
735 + .expect("persist_inbound_request ok");
736 + assert_eq!(row.handle, "alice@a.tld");
737 + assert_eq!(row.domain, "b.tld");
738 + assert_eq!(row.requested_remote_id, Some(requester_id));
739 + }
740 +
741 + #[tokio::test]
742 + async fn persist_inbound_request_rejects_wrong_idp_domain() {
743 + let db: DatabaseConnection =
744 + MockDatabase::new(DatabaseBackend::Postgres).into_connection();
745 + let body = serde_json::json!({
746 + "actor": "https://b.tld/",
747 + "target": "@alice@evil.tld",
748 + "nonce": "n",
749 + });
750 + let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld")
751 + .await
752 + .expect_err("must reject mismatched IdP domain");
753 + assert!(matches!(err, AppError::Validation(_)), "got {err:?}");
754 + }
755 +
756 + #[tokio::test]
757 + async fn persist_inbound_request_rejects_missing_actor() {
758 + let db: DatabaseConnection =
759 + MockDatabase::new(DatabaseBackend::Postgres).into_connection();
760 + let body = serde_json::json!({
761 + "target": "@alice@a.tld",
762 + "nonce": "n",
763 + });
764 + let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld")
765 + .await
766 + .expect_err("must reject missing actor");
767 + assert!(matches!(err, AppError::Validation(_)), "got {err:?}");
768 + }
769 +
770 + #[tokio::test]
771 + async fn persist_inbound_request_rejects_unknown_requester() {
772 + // No remote_identities row matches → service surfaces a clear
773 + // Validation error rather than inserting a stray challenge.
774 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
775 + .append_query_results([Vec::<crate::models::remote_identity::Model>::new()])
776 + .into_connection();
777 + let body = serde_json::json!({
778 + "actor": "https://unknown.tld/",
779 + "target": "@alice@a.tld",
780 + "nonce": "n",
781 + });
782 + let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld")
783 + .await
784 + .expect_err("must reject unknown requester");
785 + assert!(matches!(err, AppError::Validation(_)), "got {err:?}");
786 + }
577 787 }
A crates/rustwarden-core/tests/middleware_authconfig_fallback.rs
+77 -0
@@ -0,0 +1,77 @@
1 +//! RFC 0022 — anti-régression : `AuthService` / `AdminService` /
2 +//! `admin_helper` doivent retomber sur `AuthConfig::from_env()` quand
3 +//! le consommateur n'injecte pas `Extension(Arc<AuthConfig>)` *avant*
4 +//! l'auth middleware dans son `ServiceBuilder`. Le commit `d4d6828`
5 +//! avait introduit un `500` silencieux dans ce cas.
6 +//!
7 +//! Ce fichier vérifie le pré-requis du fallback : `AuthConfig::from_env()`
8 +//! tourne avec un set minimal de variables d'environnement, comme le
9 +//! cas de prod. Si cette assertion casse, la nouvelle branche fallback
10 +//! de `AuthService::call` est inutile.
11 +
12 +use std::sync::Mutex;
13 +
14 +use rustwarden_core::config::auth::AuthConfig;
15 +
16 +static ENV_LOCK: Mutex<()> = Mutex::new(());
17 +
18 +fn set_minimal_env() {
19 + // SAFETY: tests under #[tokio::test] run on the same process; the
20 + // mutex above serialises mutations.
21 + unsafe {
22 + std::env::set_var(
23 + "JWT_SECRET",
24 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
25 + );
26 + // Permit non-HTTPS cookies in dev so `from_env` validates without
27 + // requiring a TLS endpoint configured.
28 + std::env::set_var("APP_DEBUG", "true");
29 + }
30 +}
31 +
32 +fn clear_env() {
33 + // SAFETY: see set_minimal_env.
34 + unsafe {
35 + std::env::remove_var("JWT_SECRET");
36 + std::env::remove_var("APP_DEBUG");
37 + }
38 +}
39 +
40 +#[test]
41 +fn auth_config_from_env_succeeds_with_minimal_vars() {
42 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
43 + set_minimal_env();
44 + let cfg = AuthConfig::from_env();
45 + assert!(
46 + cfg.is_ok(),
47 + "AuthConfig::from_env must succeed with JWT_SECRET set: got {cfg:?}",
48 + );
49 +}
50 +
51 +#[test]
52 +fn auth_config_from_env_fails_without_jwt_secret() {
53 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
54 + clear_env();
55 + // Without JWT_SECRET, from_env must surface an error rather than
56 + // silently using a default — the fallback in AuthService surfaces
57 + // this as a `tracing::error!` line and a 500.
58 + let cfg = AuthConfig::from_env();
59 + assert!(
60 + cfg.is_err(),
61 + "AuthConfig::from_env must reject missing JWT_SECRET: got {cfg:?}",
62 + );
63 +}
64 +
65 +#[test]
66 +fn auth_config_from_env_is_idempotent() {
67 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
68 + set_minimal_env();
69 + let a = AuthConfig::from_env().expect("first load");
70 + let b = AuthConfig::from_env().expect("second load");
71 + // Two consecutive calls must yield equal jwt_expiration_minutes —
72 + // the trait-less smoke check that the env-driven loader is stable.
73 + assert_eq!(
74 + a.jwt_expiration_minutes, b.jwt_expiration_minutes,
75 + "AuthConfig::from_env must be deterministic across calls",
76 + );
77 +}
M crates/rustwarden-ui/Cargo.toml
+7 -1
@@ -35,12 +35,18 @@ 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 `experimental-fediverse` is on.
40 +futures = { version = "0.3", optional = true }
38 41
39 42 [features]
40 43 default = []
41 44 # RFC 0001: pass-through to enable federation primitives in core. UI templates
42 45 # and handlers gated by this feature land in subsequent RFCs (0002+).
43 -experimental-fediverse = ["rustwarden-core/experimental-fediverse"]
46 +experimental-fediverse = [
47 + "rustwarden-core/experimental-fediverse",
48 + "dep:futures",
49 +]
44 50
45 51 [lints]
46 52 workspace = true
M crates/rustwarden-ui/locales/en.yml
+199 -0
@@ -625,3 +625,202 @@ fediverse:
625 625 authorized_fetch_desc: "Require an HTTP signature to fetch a remote actor. Enable after audit."
626 626 save_button: "Save"
627 627 saved_toast: "Settings saved."
628 + trust:
629 + title: "Trusted domains"
630 + breadcrumb: "Trust"
631 + heading: "Federation trust list"
632 + subtitle: "Manage domains accepted or blocked for ActivityPub federation. The active mode (allowlist or blocklist) is set on the Federation settings page."
633 + mode_allowlist: "Allowlist mode (zero-trust)"
634 + mode_blocklist: "Blocklist mode (Mastodon-style)"
635 + add_legend: "Add a domain"
636 + domain_label: "Domain"
637 + domain_placeholder: "example.tld"
638 + note_label: "Note (optional)"
639 + reason_label: "Block reason (optional)"
640 + severity_label: "Severity"
641 + severity_suspend: "Suspend (full reject)"
642 + severity_silence: "Silence (quiet filtering)"
643 + severity_reject_auth: "Reject auth only"
644 + add_button: "Add"
645 + table_domain: "Domain"
646 + table_note_or_reason: "Note / Reason"
647 + table_severity: "Severity"
648 + table_created_at: "Added on"
649 + table_actions: "Actions"
650 + delete_button: "Delete"
651 + delete_confirm_title: "Confirm deletion"
652 + delete_confirm_body: "This action is irreversible. The domain below will be removed from the active list."
653 + modal_cancel: "Cancel"
654 + modal_confirm: "Confirm deletion"
655 + empty_title: "Empty list"
656 + empty_body: "No domain is configured. Add one to start federating."
657 + saved_toast: "List updated."
658 + auth:
659 + error:
660 + page_title: "Federated authentication error"
661 + subtitle: "An error occurred during ActivityPub authentication."
662 + back_to_login: "Back to login"
663 + timeout:
664 + title: "Request timed out"
665 + body: "Your origin instance did not respond in time. Make sure it is reachable and try again shortly."
666 + rejected:
667 + title: "Request rejected"
668 + body: "Your origin instance rejected the authentication request. If you did not initiate this attempt, you can safely ignore it."
669 + untrusted:
670 + title: "Untrusted domain"
671 + body: "Your identity's domain is not in this instance's trust list. Contact the administrator if you believe this is a mistake."
672 + invalid_signature:
673 + title: "Invalid signature"
674 + body: "Your origin instance's signature could not be verified. This may indicate a misconfiguration or a malicious attempt."
675 + unknown:
676 + title: "Unknown error"
677 + body: "An unexpected error occurred. Try again or contact the administrator if the problem persists."
678 + login:
679 + page_title: "ActivityPub login"
680 + subtitle: "Sign in with an identity hosted on another federated instance."
681 + heading: "Sign in with an ActivityPub identity"
682 + field_label: "Identity"
683 + field_placeholder: "@user@instance.tld"
684 + field_help: "Mastodon format: @ followed by the username, then @, then the instance domain."
685 + submit: "Continue"
686 + back_to_login: "Back to the classic login"
687 + waiting:
688 + page_title: "Signing you in"
689 + subtitle: "Hang tight while your origin instance approves the request."
690 + heading: "Signing you in…"
691 + explainer: "Your origin instance needs to confirm your identity. This page will update automatically as soon as a response arrives (up to 5 minutes)."
692 + cancel: "Cancel"
693 + consent:
694 + page_title: "Confirm a federated sign-in"
695 + heading: "An instance is requesting authentication"
696 + explainer: "A federated instance wants to authenticate you on its side. Verify the domain and identity below before approving."
697 + label_requester: "Requesting domain"
698 + label_handle: "Identity at stake"
699 + approve: "Approve"
700 + reject: "Reject"
701 + already_decided: "This request has already been handled."
702 + queue:
703 + title: "Federation outbound queue"
704 + breadcrumb: "Outbound queue"
705 + heading: "Outbound deliveries"
706 + subtitle: "Dashboard of ActivityPub activities to dispatch to remote instances. Last 100 rows displayed."
707 + stats_pending: "Pending"
708 + stats_delivered: "Delivered"
709 + stats_failed: "Failed"
710 + stats_dead: "Dead"
711 + table_activity: "Activity"
712 + table_target: "Target"
713 + table_status: "Status"
714 + table_attempts: "Attempts"
715 + table_last_error: "Last error"
716 + table_next_retry: "Next retry"
717 + table_created_at: "Created at"
718 + empty_title: "Empty queue"
719 + empty_body: "No outbound activity. The worker is healthy or federation has not produced activity yet."
720 + sessions_remote:
721 + title: "Remote sessions"
722 + breadcrumb: "Remote sessions"
723 + heading: "Active remote sessions"
724 + subtitle: "Federated identities that contacted this instance and are not disabled. Revoke = mark as disabled (matching remote JWT sessions will be rejected)."
725 + kill_switch_legend: "Kill switch by domain"
726 + kill_switch_label: "Disable all identities from a domain"
727 + kill_switch_button: "Kill domain"
728 + kill_switch_confirm_title: "Confirm kill switch"
729 + kill_switch_confirm_body: "All federated identities for the domain below will be marked as disabled. This action is irreversible from the UI (a new federated login will recreate the row)."
730 + table_handle: "Handle"
731 + table_display_name: "Display name"
732 + table_domain: "Domain"
733 + table_last_seen: "Last seen"
734 + table_actions: "Actions"
735 + revoke_button: "Revoke"
736 + revoke_confirm_title: "Confirm revocation"
737 + revoke_confirm_body: "This identity will be marked as disabled. The next remote JWT check will reject it."
738 + modal_cancel: "Cancel"
739 + modal_confirm: "Confirm"
740 + empty_title: "No remote sessions"
741 + empty_body: "No federated identity has contacted this instance yet, or all have been disabled."
742 + saved_toast: "Action completed."
743 +
744 +# Common (RFC 0024 Slice C iter. 3)
745 +common:
746 + yes: "Yes"
747 + no: "No"
748 +
749 +# NodeInfo stats (RFC 0024 Slice C iter. 3). Kept top-level dedicated
750 +# namespace to avoid re-opening `fediverse:` (which would shadow
751 +# settings/trust/auth/queue/sessions_remote under yaml-rust2).
752 +fediverse_nodeinfo:
753 + title: "NodeInfo - Federation stats"
754 + breadcrumb: "NodeInfo"
755 + heading: "NodeInfo - Humanised stats"
756 + subtitle: "Overview of runtime configuration + stats for local users and remote identities."
757 + card_software: "Software"
758 + card_runtime: "Runtime configuration"
759 + card_users: "Local users"
760 + card_remotes: "Remote identities"
761 + label_name: "Name"
762 + label_version: "Version"
763 + label_domain: "Domain"
764 + label_enabled: "Federation enabled"
765 + label_trust_mode: "Trust mode"
766 + label_interop_scope: "Interop scope"
767 + label_authorized_fetch: "Authorized-fetch"
768 + label_users_total: "Total"
769 + label_users_active_month: "Active 30d"
770 + label_users_active_halfyear: "Active 180d"
771 + label_remote_identities_active: "Active"
772 + label_remote_distinct_domains: "Distinct domains"
773 +
774 +# Cross-instance team invitations (RFC 0024 Slice C iter. 4).
775 +fediverse_invites:
776 + title: "Federated team invitations"
777 + breadcrumb: "Invitations"
778 + heading: "Cross-instance invitations"
779 + subtitle: "Invite an ActivityPub identity to join the team:"
780 + add_legend: "Invite by handle"
781 + handle_label: "User handle"
782 + handle_placeholder: "@user@instance.tld"
783 + role_label: "Role"
784 + role_member: "Member"
785 + role_admin: "Administrator"
786 + role_owner: "Owner"
787 + add_button: "Send invitation"
788 + table_handle: "Invited handle"
789 + table_role: "Role"
790 + table_status: "Status"
791 + table_created_at: "Sent at"
792 + table_actions: "Actions"
793 + revoke_button: "Revoke"
794 + revoke_confirm_title: "Confirm revocation"
795 + revoke_confirm_body: "The invitation will be cancelled. If already accepted, the identity will be removed from the team via the Revoke{Membership} signal (RFC 0010)."
796 + modal_cancel: "Cancel"
797 + modal_confirm: "Confirm"
798 + empty_title: "No invitations"
799 + empty_body: "No invitation has been sent for this team yet."
800 + saved_toast: "Action completed."
801 +
802 +# Connected federated apps (RFC 0024 Slice C iter. 5).
803 +fediverse_connected_apps:
804 + page_title: "Connected federated apps"
805 + heading: "My connected federated apps"
806 + subtitle: "ActivityPub instances where you authenticated using your identity on this server."
807 + table_domain: "Instance"
808 + table_count: "Sessions"
809 + table_last_seen: "Last activity"
810 + empty_title: "No federated apps"
811 + empty_body: "You have not approved any federated authentication to another instance yet."
812 + revoke_note: "Note: manual revocation of a remote session from this page will be available with RFC 0006 Phase 2 (remote JWT blacklisting). Meanwhile, ask the admin via /admin/fediverse/sessions_remote or wait for the JWT to expire naturally."
813 + back_to_profile: "Back to profile"
814 +
815 +# Cross-instance team invitation acceptance (RFC 0024 Slice C iter. 6).
816 +fediverse_invite_landing:
817 + page_title: "Team invitation"
818 + heading: "You received an invitation"
819 + explainer: "A team from another federated instance invites you to join. Verify the details below before responding."
820 + team_label: "Team"
821 + role_label: "Proposed role"
822 + status_label: "Status"
823 + accept_button: "Accept"
824 + reject_button: "Reject"
825 + already_decided: "This invitation has already been handled."
826 + cannot_accept_note: "To accept, you must be signed in via a federated identity (login from your origin instance). You can reject from this local account, but accepting goes through the AP-native flow."
M crates/rustwarden-ui/locales/fr.yml
+200 -0
@@ -725,3 +725,203 @@ fediverse:
725 725 authorized_fetch_desc: "Exiger une signature HTTP pour récupérer un acteur distant. À activer après audit."
726 726 save_button: "Enregistrer"
727 727 saved_toast: "Réglages enregistrés."
728 + trust:
729 + title: "Domaines de confiance"
730 + breadcrumb: "Confiance"
731 + heading: "Liste de confiance fédération"
732 + subtitle: "Gère les domaines acceptés ou bloqués pour la fédération ActivityPub. Le mode actif (liste blanche ou liste de blocage) est défini sur la page Réglages fédération."
733 + mode_allowlist: "Mode liste blanche (zero-trust)"
734 + mode_blocklist: "Mode liste de blocage (style Mastodon)"
735 + add_legend: "Ajouter un domaine"
736 + domain_label: "Domaine"
737 + domain_placeholder: "exemple.tld"
738 + note_label: "Note (optionnelle)"
739 + reason_label: "Raison du blocage (optionnelle)"
740 + severity_label: "Sévérité"
741 + severity_suspend: "Suspension (rejet total)"
742 + severity_silence: "Silence (filtrage discret)"
743 + severity_reject_auth: "Refus d'authentification uniquement"
744 + add_button: "Ajouter"
745 + table_domain: "Domaine"
746 + table_note_or_reason: "Note / Raison"
747 + table_severity: "Sévérité"
748 + table_created_at: "Ajouté le"
749 + table_actions: "Actions"
750 + delete_button: "Supprimer"
751 + delete_confirm_title: "Confirmer la suppression"
752 + delete_confirm_body: "Cette action est irréversible. Le domaine ci-dessous sera retiré de la liste active."
753 + modal_cancel: "Annuler"
754 + modal_confirm: "Confirmer la suppression"
755 + empty_title: "Liste vide"
756 + empty_body: "Aucun domaine n'est configuré. Ajoute un domaine pour commencer à fédérer."
757 + saved_toast: "Liste mise à jour."
758 + auth:
759 + error:
760 + page_title: "Erreur d'authentification fédérée"
761 + subtitle: "Une erreur est survenue lors de l'authentification ActivityPub."
762 + back_to_login: "Retour à la connexion"
763 + timeout:
764 + title: "Délai dépassé"
765 + body: "Votre instance d'origine n'a pas répondu dans le temps imparti. Vérifie qu'elle est joignable et réessaie dans quelques instants."
766 + rejected:
767 + title: "Demande refusée"
768 + body: "Votre instance d'origine a refusé la demande d'authentification. Si tu n'es pas à l'origine de cette tentative, ignore-la."
769 + untrusted:
770 + title: "Domaine non autorisé"
771 + body: "Le domaine de votre identité n'est pas dans la liste de confiance de cette instance. Contacte l'administrateur si tu penses que c'est une erreur."
772 + invalid_signature:
773 + title: "Signature invalide"
774 + body: "La signature de votre instance d'origine n'a pas pu être vérifiée. Cela peut indiquer une mauvaise configuration ou une tentative malveillante."
775 + unknown:
776 + title: "Erreur inconnue"
777 + body: "Une erreur inattendue est survenue. Réessaie ou contacte l'administrateur si le problème persiste."
778 + login:
779 + page_title: "Connexion ActivityPub"
780 + subtitle: "Connecte-toi avec une identité hébergée sur une autre instance fédérée."
781 + heading: "Se connecter avec une identité ActivityPub"
782 + field_label: "Identité"
783 + field_placeholder: "@user@instance.tld"
784 + field_help: "Format Mastodon : @ suivi du nom d'utilisateur, puis @, puis le domaine de l'instance."
785 + submit: "Continuer"
786 + back_to_login: "Retour à la connexion classique"
787 + waiting:
788 + page_title: "Authentification en cours"
789 + subtitle: "Patiente pendant que ton instance d'origine valide la demande."
790 + heading: "Authentification en cours…"
791 + explainer: "Ton instance d'origine doit confirmer ton identité. Cette page se mettra à jour automatiquement dès qu'une réponse arrive (jusqu'à 5 minutes)."
792 + cancel: "Annuler"
793 + consent:
794 + page_title: "Confirmer une connexion fédérée"
795 + heading: "Une instance demande une authentification"
796 + explainer: "Une instance fédérée veut t'authentifier auprès d'elle en ton nom. Vérifie le domaine et l'identité ci-dessous avant d'approuver."
797 + label_requester: "Domaine demandeur"
798 + label_handle: "Identité concernée"
799 + approve: "Approuver"
800 + reject: "Refuser"
801 + already_decided: "Cette demande a déjà été traitée."
802 + queue:
803 + title: "File outbound fédération"
804 + breadcrumb: "File outbound"
805 + heading: "Livraisons sortantes"
806 + subtitle: "Tableau de bord des activités ActivityPub à distribuer aux instances distantes. 100 dernières lignes affichées."
807 + stats_pending: "En attente"
808 + stats_delivered: "Livrées"
809 + stats_failed: "Échouées"
810 + stats_dead: "Mortes"
811 + table_activity: "Activité"
812 + table_target: "Cible"
813 + table_status: "Statut"
814 + table_attempts: "Tentatives"
815 + table_last_error: "Dernière erreur"
816 + table_next_retry: "Prochain essai"
817 + table_created_at: "Créée le"
818 + empty_title: "File vide"
819 + empty_body: "Aucune activité sortante. Le worker est sain ou la fédération n'a pas encore d'activité."
820 + sessions_remote:
821 + title: "Sessions distantes"
822 + breadcrumb: "Sessions distantes"
823 + heading: "Sessions distantes actives"
824 + subtitle: "Identités fédérées qui ont contacté cette instance et ne sont pas désactivées. Action de révocation = mise en disabled (les sessions JWT remote correspondantes seront rejetées)."
825 + kill_switch_legend: "Kill switch par domaine"
826 + kill_switch_label: "Désactiver toutes les identités d'un domaine"
827 + kill_switch_button: "Désactiver le domaine"
828 + kill_switch_confirm_title: "Confirmer le kill switch"
829 + kill_switch_confirm_body: "Toutes les identités fédérées du domaine ci-dessous seront mises en disabled. Cette action est irréversible côté UI (un nouveau login fédéré recréera la row)."
830 + table_handle: "Handle"
831 + table_display_name: "Nom affiché"
832 + table_domain: "Domaine"
833 + table_last_seen: "Vu pour la dernière fois"
834 + table_actions: "Actions"
835 + revoke_button: "Révoquer"
836 + revoke_confirm_title: "Confirmer la révocation"
837 + revoke_confirm_body: "Cette identité sera mise en disabled. La prochaine vérification JWT remote la rejettera."
838 + modal_cancel: "Annuler"
839 + modal_confirm: "Confirmer"
840 + empty_title: "Aucune session distante"
841 + empty_body: "Aucune identité fédérée n'a encore contacté cette instance, ou toutes ont été désactivées."
842 + saved_toast: "Action effectuée."
843 +
844 +# Communs (RFC 0024 Slice C iter. 3)
845 +common:
846 + yes: "Oui"
847 + no: "Non"
848 +
849 +# Stats NodeInfo (RFC 0024 Slice C iter. 3). Gardé en namespace
850 +# top-level dédié pour ne pas re-ouvrir `fediverse:` (qui écraserait
851 +# settings/trust/auth/queue/sessions_remote sous yaml-rust2).
852 +fediverse_nodeinfo:
853 + title: "NodeInfo - Stats fédération"
854 + breadcrumb: "NodeInfo"
855 + heading: "NodeInfo - Stats humanisées"
856 + subtitle: "Vue d'ensemble de la configuration runtime + stats utilisateurs locaux et identités distantes."
857 + card_software: "Logiciel"
858 + card_runtime: "Configuration runtime"
859 + card_users: "Utilisateurs locaux"
860 + card_remotes: "Identités distantes"
861 + label_name: "Nom"
862 + label_version: "Version"
863 + label_domain: "Domaine"
864 + label_enabled: "Fédération activée"
865 + label_trust_mode: "Mode de confiance"
866 + label_interop_scope: "Périmètre interop"
867 + label_authorized_fetch: "Authorized-fetch"
868 + label_users_total: "Total"
869 + label_users_active_month: "Actifs 30j"
870 + label_users_active_halfyear: "Actifs 180j"
871 + label_remote_identities_active: "Actives"
872 + label_remote_distinct_domains: "Domaines distincts"
873 +
874 +# Invitations cross-instance team (RFC 0024 Slice C iter. 4) — namespace
875 +# top-level dédié pour ne pas re-ouvrir `fediverse:`.
876 +fediverse_invites:
877 + title: "Invitations équipe fédérée"
878 + breadcrumb: "Invitations"
879 + heading: "Invitations cross-instance"
880 + subtitle: "Inviter une identité ActivityPub à rejoindre l'équipe :"
881 + add_legend: "Inviter par handle"
882 + handle_label: "Handle de l'utilisateur"
883 + handle_placeholder: "@user@instance.tld"
884 + role_label: "Rôle"
885 + role_member: "Membre"
886 + role_admin: "Administrateur"
887 + role_owner: "Propriétaire"
888 + add_button: "Envoyer l'invitation"
889 + table_handle: "Handle invité"
890 + table_role: "Rôle"
891 + table_status: "Statut"
892 + table_created_at: "Envoyée le"
893 + table_actions: "Actions"
894 + revoke_button: "Révoquer"
895 + revoke_confirm_title: "Confirmer la révocation"
896 + revoke_confirm_body: "L'invitation sera annulée. Si elle a déjà été acceptée, l'identité sera retirée de l'équipe par le signal Revoke{Membership} (RFC 0010)."
897 + modal_cancel: "Annuler"
898 + modal_confirm: "Confirmer"
899 + empty_title: "Aucune invitation"
900 + empty_body: "Aucune invitation n'a encore été envoyée pour cette équipe."
901 + saved_toast: "Action effectuée."
902 +
903 +# Apps fédérées connectées (RFC 0024 Slice C iter. 5).
904 +fediverse_connected_apps:
905 + page_title: "Apps fédérées connectées"
906 + heading: "Mes apps fédérées connectées"
907 + subtitle: "Instances ActivityPub où tu t'es authentifié·e avec ton identité de cette instance."
908 + table_domain: "Instance"
909 + table_count: "Sessions"
910 + table_last_seen: "Dernière activité"
911 + empty_title: "Aucune app fédérée"
912 + empty_body: "Tu n'as pas encore approuvé d'authentification fédérée vers une autre instance."
913 + revoke_note: "Note: la révocation manuelle d'une session distante depuis cette page sera disponible avec RFC 0006 Phase 2 (blacklist JWT remote). En attendant, demande à l'admin via /admin/fediverse/sessions_remote ou attend l'expiration naturelle du JWT."
914 + back_to_profile: "Retour au profil"
915 +
916 +# Page d'acceptation invitation team cross-instance (RFC 0024 Slice C iter. 6).
917 +fediverse_invite_landing:
918 + page_title: "Invitation à rejoindre une équipe"
919 + heading: "Tu as reçu une invitation"
920 + explainer: "Une équipe d'une autre instance fédérée t'invite à la rejoindre. Vérifie les détails ci-dessous avant de répondre."
921 + team_label: "Équipe"
922 + role_label: "Rôle proposé"
923 + status_label: "Statut"
924 + accept_button: "Accepter"
925 + reject_button: "Refuser"
926 + already_decided: "Cette invitation a déjà été traitée."
927 + cannot_accept_note: "Pour accepter, tu dois être connecté·e via une identité fédérée (login depuis ton instance d'origine). Tu peux refuser depuis ce compte local, mais l'acceptation passe par le flow AP-native."
M crates/rustwarden-ui/src/handlers/admin_fediverse.rs
+1297 -0
@@ -29,6 +29,20 @@ use rustwarden_core::i18n::extractor::Locale;
29 29 use rustwarden_core::i18n::service::I18nService;
30 30 use rustwarden_core::middleware::csrf;
31 31 use rustwarden_core::services::app_settings_service::AppSettingsService;
32 +use rustwarden_core::services::fediverse_trust_service::{
33 + BlockSeverity, FediverseTrustService, TrustMode,
34 +};
35 +
36 +use rustwarden_core::models::fediverse_outbound_queue;
37 +use rustwarden_core::models::remote_identity;
38 +use rustwarden_core::models::user as user_model;
39 +use rustwarden_core::models::{fediverse_pending_invite, team};
40 +use rustwarden_core::services::fediverse_invite_service::{
41 + FediverseInviteService, InviteStatus,
42 +};
43 +use rustwarden_core::services::team_service::TeamRole;
44 +use rustwarden_core::services::fediverse_outbound_service::OutboundStatus;
45 +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
32 46
33 47 use crate::dto::NavigationDTO;
34 48 use crate::template_helpers::build_base_context_with_claims;
@@ -377,3 +391,1286 @@ pub async fn update_settings(
377 391
378 392 Ok(Redirect::to("/admin/fediverse/settings?saved=1"))
379 393 }
394 +
395 +// ─────────────────────────────────────────────────────────────────────
396 +// /admin/fediverse/trust_list (RFC 0021 Slice A iter. 2)
397 +// ─────────────────────────────────────────────────────────────────────
398 +
399 +/// One row rendered into the trust-list table. Whether it represents a
400 +/// trusted or a blocked domain is conveyed by the surrounding template
401 +/// state (`is_blocklist`).
402 +#[derive(Debug, Clone)]
403 +pub struct TrustEntryCtx {
404 + /// Normalised domain.
405 + pub domain: String,
406 + /// Optional admin note (allowlist) or blocking reason (blocklist).
407 + pub note_or_reason: String,
408 + /// `"true"` when [`Self::note_or_reason`] is non-empty (Askama lacks
409 + /// a clean `Option` ergonomic for templates).
410 + pub has_note_or_reason: bool,
411 + /// Severity (blocklist only): `suspend` / `silence` / `reject_auth`.
412 + /// Empty string for trusted entries.
413 + pub severity: String,
414 + /// `created_at` formatted as `YYYY-MM-DD`.
415 + pub created_at: String,
416 +}
417 +
418 +/// Form payload for `POST /admin/fediverse/trust_list/add`.
419 +#[derive(Debug, Deserialize)]
420 +pub struct AddTrustEntryForm {
421 + /// CSRF token from the hidden form field.
422 + pub csrf_token: String,
423 + /// Domain submitted by the admin (free-form, normalised on insert).
424 + pub domain: String,
425 + /// Optional note (allowlist) or reason (blocklist).
426 + #[serde(default)]
427 + pub note_or_reason: String,
428 + /// Blocklist severity: `suspend` / `silence` / `reject_auth`. Ignored
429 + /// when the active mode is `allowlist`.
430 + #[serde(default)]
431 + pub severity: String,
432 +}
433 +
434 +/// Form payload for `POST /admin/fediverse/trust_list/delete`.
435 +#[derive(Debug, Deserialize)]
436 +pub struct DeleteTrustEntryForm {
437 + /// CSRF token from the hidden form field.
438 + pub csrf_token: String,
439 + /// Domain to remove from the active list.
440 + pub domain: String,
441 +}
442 +
443 +/// Askama template for `GET /admin/fediverse/trust_list`. The fields
444 +/// below carry pre-translated UI strings whose intent is conveyed by
445 +/// their names; verbose per-field rustdoc would be noise on a 38-key
446 +/// table.
447 +#[derive(Template)]
448 +#[template(path = "admin/fediverse/trust_list.html")]
449 +#[allow(dead_code, missing_docs)]
450 +pub struct FediverseTrustListTemplate {
451 + /// Browser tab + page title.
452 + pub page_title: String,
453 + /// CSRF token for the add + delete forms.
454 + pub csrf_token: String,
455 + /// App display fields shared with `settings.html`.
456 + pub app_name: String,
457 + pub app_version: String,
458 + pub app_domain: String,
459 + pub app_description: String,
460 + pub username: String,
461 + pub email: String,
462 + pub initials: String,
463 + pub avatar_color: String,
464 + pub roles_text: String,
465 + pub has_pending_password_change: bool,
466 + pub nav_items: Vec<NavItem>,
467 + pub user_menu_profile: String,
468 + pub user_menu_logout: String,
469 + /// Toast banner shown when `?saved=1`.
470 + pub saved_flash: bool,
471 + /// `true` when the active mode is `open_with_blocklist` — drives
472 + /// table headers and form fields (severity selector vs note).
473 + pub is_blocklist: bool,
474 + /// Pre-loaded entries.
475 + pub entries: Vec<TrustEntryCtx>,
476 + /// Whether [`Self::entries`] is empty (Askama lacks `is_empty()`
477 + /// in stable expressions across versions).
478 + pub has_entries: bool,
479 +
480 + // i18n
481 + pub heading: String,
482 + pub subtitle: String,
483 + pub mode_indicator: String,
484 + pub add_form_legend: String,
485 + pub domain_label: String,
486 + pub domain_placeholder: String,
487 + pub note_label: String,
488 + pub reason_label: String,
489 + pub severity_label: String,
490 + pub severity_suspend: String,
491 + pub severity_silence: String,
492 + pub severity_reject_auth: String,
493 + pub add_button: String,
494 + pub table_domain: String,
495 + pub table_note_or_reason: String,
496 + pub table_severity: String,
497 + pub table_created_at: String,
498 + pub table_actions: String,
499 + pub delete_button: String,
500 + pub delete_confirm_title: String,
501 + pub delete_confirm_body: String,
502 + pub modal_cancel: String,
503 + pub modal_confirm: String,
504 + pub empty_title: String,
505 + pub empty_body: String,
506 + pub saved_toast: String,
507 + pub breadcrumb_home: String,
508 + pub breadcrumb_admin: String,
509 + pub breadcrumb_self: String,
510 +}
511 +
512 +rustwarden_core::impl_template_response!(FediverseTrustListTemplate);
513 +
514 +/// Allowed `severity` form values (RFC 0004).
515 +const SEVERITIES: &[&str] = &["suspend", "silence", "reject_auth"];
516 +
517 +/// Render `GET /admin/fediverse/trust_list`.
518 +///
519 +/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` so the
520 +/// page is invisible while the feature is off. The active list (allow
521 +/// vs block) is selected from `app_settings.fediverse_trust_mode`.
522 +pub async fn get_trust_list(
523 + Extension(claims): Extension<Claims>,
524 + Extension(i18n_service): Extension<Arc<I18nService>>,
525 + Extension(locale): Extension<Locale>,
526 + State(db): State<DatabaseConnection>,
527 + session: Session,
528 + axum::extract::Query(query): axum::extract::Query<SavedQuery>,
529 +) -> Result<axum::response::Response, AppError> {
530 + let trust = FediverseTrustService::new(db.clone());
531 +
532 + if !trust.is_enabled().await? {
533 + return Ok((
534 + axum::http::StatusCode::NOT_FOUND,
535 + "Federation disabled",
536 + )
537 + .into_response());
538 + }
539 +
540 + let locale_str = locale.as_str();
541 + let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
542 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
543 +
544 + let mode = trust.trust_mode().await?;
545 + let is_blocklist = matches!(mode, TrustMode::OpenWithBlocklist);
546 +
547 + let entries: Vec<TrustEntryCtx> = if is_blocklist {
548 + trust
549 + .list_blocked_domains()
550 + .await?
551 + .into_iter()
552 + .map(|m| TrustEntryCtx {
553 + domain: m.domain,
554 + has_note_or_reason: m.reason.as_deref().is_some_and(|s| !s.is_empty()),
555 + note_or_reason: m.reason.unwrap_or_default(),
556 + severity: m.severity,
557 + created_at: m.created_at.format("%Y-%m-%d").to_string(),
558 + })
559 + .collect()
560 + } else {
561 + trust
562 + .list_trusted_domains()
563 + .await?
564 + .into_iter()
565 + .map(|m| TrustEntryCtx {
566 + domain: m.domain,
567 + has_note_or_reason: m.note.as_deref().is_some_and(|s| !s.is_empty()),
568 + note_or_reason: m.note.unwrap_or_default(),
569 + severity: String::new(),
570 + created_at: m.created_at.format("%Y-%m-%d").to_string(),
571 + })
572 + .collect()
573 + };
574 + let has_entries = !entries.is_empty();
575 +
576 + let t = |k: &str| {
577 + let i18n = i18n_service.clone();
578 + let key = k.to_string();
579 + let loc = locale_str.to_string();
580 + async move { i18n.translate(&key, &loc).await }
581 + };
582 +
583 + let mode_indicator = if is_blocklist {
584 + t("fediverse.trust.mode_blocklist").await
585 + } else {
586 + t("fediverse.trust.mode_allowlist").await
587 + };
588 +
589 + let template = FediverseTrustListTemplate {
590 + page_title: t("fediverse.trust.title").await,
591 + csrf_token,
592 + app_name: base_context.app_name,
593 + app_version: base_context.app_version,
594 + app_domain: base_context.app_domain,
595 + app_description: base_context.app_description,
596 + username: base_context.username,
597 + email: base_context.email,
598 + initials: base_context.initials,
599 + avatar_color: base_context.avatar_color,
600 + roles_text: base_context.roles.join(", "),
601 + has_pending_password_change: false,
602 + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
603 + .await,
604 + user_menu_profile: t("user_menu.profile").await,
605 + user_menu_logout: t("user_menu.logout").await,
606 + saved_flash: query.saved.unwrap_or(0) == 1,
607 + is_blocklist,
608 + entries,
609 + has_entries,
610 + heading: t("fediverse.trust.heading").await,
611 + subtitle: t("fediverse.trust.subtitle").await,
612 + mode_indicator,
613 + add_form_legend: t("fediverse.trust.add_legend").await,
614 + domain_label: t("fediverse.trust.domain_label").await,
615 + domain_placeholder: t("fediverse.trust.domain_placeholder").await,
616 + note_label: t("fediverse.trust.note_label").await,
617 + reason_label: t("fediverse.trust.reason_label").await,
618 + severity_label: t("fediverse.trust.severity_label").await,
619 + severity_suspend: t("fediverse.trust.severity_suspend").await,
620 + severity_silence: t("fediverse.trust.severity_silence").await,
621 + severity_reject_auth: t("fediverse.trust.severity_reject_auth").await,
622 + add_button: t("fediverse.trust.add_button").await,
623 + table_domain: t("fediverse.trust.table_domain").await,
624 + table_note_or_reason: t("fediverse.trust.table_note_or_reason").await,
625 + table_severity: t("fediverse.trust.table_severity").await,
626 + table_created_at: t("fediverse.trust.table_created_at").await,
627 + table_actions: t("fediverse.trust.table_actions").await,
628 + delete_button: t("fediverse.trust.delete_button").await,
629 + delete_confirm_title: t("fediverse.trust.delete_confirm_title").await,
630 + delete_confirm_body: t("fediverse.trust.delete_confirm_body").await,
631 + modal_cancel: t("fediverse.trust.modal_cancel").await,
632 + modal_confirm: t("fediverse.trust.modal_confirm").await,
633 + empty_title: t("fediverse.trust.empty_title").await,
634 + empty_body: t("fediverse.trust.empty_body").await,
635 + saved_toast: t("fediverse.trust.saved_toast").await,
636 + breadcrumb_home: t("breadcrumb.home").await,
637 + breadcrumb_admin: t("breadcrumb.admin").await,
638 + breadcrumb_self: t("fediverse.trust.breadcrumb").await,
639 + };
640 +
641 + Ok(template.into_response())
642 +}
643 +
644 +/// Apply `POST /admin/fediverse/trust_list/add`. Adds an entry to the
645 +/// allowlist or the blocklist depending on the current `trust_mode`.
646 +pub async fn add_trust_entry(
647 + Extension(claims): Extension<Claims>,
648 + State(db): State<DatabaseConnection>,
649 + session: Session,
650 + Form(form): Form<AddTrustEntryForm>,
651 +) -> Result<impl IntoResponse, AppError> {
652 + csrf::validate_csrf_token(&session, &form.csrf_token).await?;
653 +
654 + let domain = form.domain.trim();
655 + if domain.is_empty() {
656 + return Err(AppError::Validation("domain must not be empty".into()));
657 + }
658 +
659 + let trust = FediverseTrustService::new(db.clone());
660 + if !trust.is_enabled().await? {
661 + return Ok(axum::http::StatusCode::NOT_FOUND.into_response());
662 + }
663 + let mode = trust.trust_mode().await?;
664 + let added_by = Some(claims.sub);
665 + let note_opt = if form.note_or_reason.trim().is_empty() {
666 + None
667 + } else {
668 + Some(form.note_or_reason.trim())
669 + };
670 +
671 + match mode {
672 + TrustMode::Allowlist => {
673 + trust
674 + .add_trusted_domain(domain, note_opt, added_by)
675 + .await?;
676 + }
677 + TrustMode::OpenWithBlocklist => {
678 + if !SEVERITIES.contains(&form.severity.as_str()) {
679 + return Err(AppError::Validation(format!(
680 + "invalid severity: {}",
681 + form.severity
682 + )));
683 + }
684 + let severity = BlockSeverity::parse(&form.severity);
685 + trust
686 + .add_blocked_domain(domain, severity, note_opt, added_by)
687 + .await?;
688 + }
689 + }
690 +
691 + Ok(Redirect::to("/admin/fediverse/trust_list?saved=1").into_response())
692 +}
693 +
694 +/// Apply `POST /admin/fediverse/trust_list/delete`. Removes an entry by
695 +/// canonical domain (URL-safe form input) from the active list.
696 +pub async fn delete_trust_entry(
697 + State(db): State<DatabaseConnection>,
698 + session: Session,
699 + Form(form): Form<DeleteTrustEntryForm>,
700 +) -> Result<impl IntoResponse, AppError> {
701 + csrf::validate_csrf_token(&session, &form.csrf_token).await?;
702 +
703 + let trust = FediverseTrustService::new(db.clone());
704 + if !trust.is_enabled().await? {
705 + return Ok(axum::http::StatusCode::NOT_FOUND.into_response());
706 + }
707 + let mode = trust.trust_mode().await?;
708 +
709 + match mode {
710 + TrustMode::Allowlist => {
711 + trust.remove_trusted_domain(&form.domain).await?;
712 + }
713 + TrustMode::OpenWithBlocklist => {
714 + trust.remove_blocked_domain(&form.domain).await?;
715 + }
716 + }
717 +
718 + Ok(Redirect::to("/admin/fediverse/trust_list?saved=1").into_response())
719 +}
720 +
721 +// ─────────────────────────────────────────────────────────────────────
722 +// /admin/fediverse/queue (RFC 0024 Slice C iter. 1)
723 +// ─────────────────────────────────────────────────────────────────────
724 +
725 +/// One row rendered into the outbound-queue table.
726 +#[derive(Debug, Clone)]
727 +pub struct OutboundQueueRowCtx {
728 + /// Activity ID (truncated for display).
729 + pub activity_id: String,
730 + /// Target inbox URL.
731 + pub target_inbox: String,
732 + /// Target domain extracted by `FediverseOutboundService::enqueue`.
733 + pub target_domain: String,
734 + /// `pending` / `delivered` / `failed` / `dead`.
735 + pub status: String,
736 + /// Number of attempts already performed.
737 + pub attempts: i32,
738 + /// Maximum attempts before terminal `dead`.
739 + pub max_attempts: i32,
740 + /// Last delivery error truncated.
741 + pub last_error: String,
742 + /// `true` when [`Self::last_error`] is non-empty.
743 + pub has_last_error: bool,
744 + /// `next_retry_at` formatted RFC3339, empty when `None`.
745 + pub next_retry_at: String,
746 + /// Insertion timestamp formatted `YYYY-MM-DD HH:MM:SS`.
747 + pub created_at: String,
748 +}
749 +
750 +/// Askama template for `GET /admin/fediverse/queue`.
751 +#[derive(Template)]
752 +#[template(path = "admin/fediverse/queue.html")]
753 +#[allow(dead_code, missing_docs)]
754 +pub struct FediverseQueueTemplate {
755 + pub page_title: String,
756 + pub csrf_token: String,
757 + pub app_name: String,
758 + pub app_version: String,
759 + pub app_domain: String,
760 + pub app_description: String,
761 + pub username: String,
762 + pub email: String,
763 + pub initials: String,
764 + pub avatar_color: String,
765 + pub roles_text: String,
766 + pub has_pending_password_change: bool,
767 + pub nav_items: Vec<NavItem>,
768 + pub user_menu_profile: String,
769 + pub user_menu_logout: String,
770 +
771 + /// Pre-loaded last 100 rows ordered by `created_at desc`.
772 + pub rows: Vec<OutboundQueueRowCtx>,
773 + /// `true` when [`Self::rows`] is empty.
774 + pub has_rows: bool,
775 + /// Per-status counts (sums all rows, not just the 100 visible).
776 + pub count_pending: u64,
777 + pub count_delivered: u64,
778 + pub count_failed: u64,
779 + pub count_dead: u64,
780 +
781 + pub heading: String,
782 + pub subtitle: String,
783 + pub stats_pending: String,
784 + pub stats_delivered: String,
785 + pub stats_failed: String,
786 + pub stats_dead: String,
787 + pub table_activity: String,
788 + pub table_target: String,
789 + pub table_status: String,
790 + pub table_attempts: String,
791 + pub table_last_error: String,
792 + pub table_next_retry: String,
793 + pub table_created_at: String,
794 + pub empty_title: String,
795 + pub empty_body: String,
796 + pub breadcrumb_home: String,
797 + pub breadcrumb_admin: String,
798 + pub breadcrumb_self: String,
799 +}
800 +
801 +rustwarden_core::impl_template_response!(FediverseQueueTemplate);
802 +
803 +/// Truncate a string to `max` chars, appending `…` when cut.
804 +fn truncate(s: &str, max: usize) -> String {
805 + if s.chars().count() <= max {
806 + s.to_string()
807 + } else {
808 + let head: String = s.chars().take(max.saturating_sub(1)).collect();
809 + format!("{head}…")
810 + }
811 +}
812 +
813 +/// Render `GET /admin/fediverse/queue` — operator dashboard for the
814 +/// outbound delivery queue (RFC 0011 + RFC 0019). Shows the last 100
815 +/// rows + per-status counts so an admin can spot a backlog at a glance.
816 +///
817 +/// Returns `404` when `fediverse_enabled = false` (Plan UI §3 P9).
818 +pub async fn get_queue(
819 + Extension(claims): Extension<Claims>,
820 + Extension(i18n_service): Extension<Arc<I18nService>>,
821 + Extension(locale): Extension<Locale>,
822 + State(db): State<DatabaseConnection>,
823 + session: Session,
824 +) -> Result<axum::response::Response, AppError> {
825 + let trust = FediverseTrustService::new(db.clone());
826 + if !trust.is_enabled().await? {
827 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
828 + }
829 +
830 + let locale_str = locale.as_str();
831 + let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
832 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
833 +
834 + let count_pending = fediverse_outbound_queue::Entity::find()
835 + .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Pending.as_str()))
836 + .count(&db)
837 + .await
838 + .unwrap_or(0);
839 + let count_delivered = fediverse_outbound_queue::Entity::find()
840 + .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Delivered.as_str()))
841 + .count(&db)
842 + .await
843 + .unwrap_or(0);
844 + let count_failed = fediverse_outbound_queue::Entity::find()
845 + .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Failed.as_str()))
846 + .count(&db)
847 + .await
848 + .unwrap_or(0);
849 + let count_dead = fediverse_outbound_queue::Entity::find()
850 + .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Dead.as_str()))
851 + .count(&db)
852 + .await
853 + .unwrap_or(0);
854 +
855 + let rows_raw = fediverse_outbound_queue::Entity::find()
856 + .order_by_desc(fediverse_outbound_queue::Column::CreatedAt)
857 + .limit(100)
858 + .all(&db)
859 + .await?;
860 +
861 + let rows: Vec<OutboundQueueRowCtx> = rows_raw
862 + .into_iter()
863 + .map(|m| OutboundQueueRowCtx {
864 + activity_id: truncate(&m.activity_id, 60),
865 + target_inbox: truncate(&m.target_inbox, 60),
866 + target_domain: m.target_domain,
867 + status: m.status,
868 + attempts: m.attempts,
869 + max_attempts: m.max_attempts,
870 + has_last_error: m.last_error.as_deref().is_some_and(|s| !s.is_empty()),
871 + last_error: m
872 + .last_error
873 + .map(|e| truncate(&e, 80))
874 + .unwrap_or_default(),
875 + next_retry_at: m
876 + .next_retry_at
877 + .map(|t| t.to_rfc3339())
878 + .unwrap_or_default(),
879 + created_at: m.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
880 + })
881 + .collect();
882 + let has_rows = !rows.is_empty();
883 +
884 + let t = |k: &str| {
885 + let i18n = i18n_service.clone();
886 + let key = k.to_string();
887 + let loc = locale_str.to_string();
888 + async move { i18n.translate(&key, &loc).await }
889 + };
890 +
891 + let template = FediverseQueueTemplate {
892 + page_title: t("fediverse.queue.title").await,
893 + csrf_token,
894 + app_name: base_context.app_name,
895 + app_version: base_context.app_version,
896 + app_domain: base_context.app_domain,
897 + app_description: base_context.app_description,
898 + username: base_context.username,
899 + email: base_context.email,
900 + initials: base_context.initials,
901 + avatar_color: base_context.avatar_color,
902 + roles_text: base_context.roles.join(", "),
903 + has_pending_password_change: false,
904 + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
905 + .await,
906 + user_menu_profile: t("user_menu.profile").await,
907 + user_menu_logout: t("user_menu.logout").await,
908 +
909 + rows,
910 + has_rows,
911 + count_pending,
912 + count_delivered,
913 + count_failed,
914 + count_dead,
915 +
916 + heading: t("fediverse.queue.heading").await,
917 + subtitle: t("fediverse.queue.subtitle").await,
918 + stats_pending: t("fediverse.queue.stats_pending").await,
919 + stats_delivered: t("fediverse.queue.stats_delivered").await,
920 + stats_failed: t("fediverse.queue.stats_failed").await,
921 + stats_dead: t("fediverse.queue.stats_dead").await,
922 + table_activity: t("fediverse.queue.table_activity").await,
923 + table_target: t("fediverse.queue.table_target").await,
924 + table_status: t("fediverse.queue.table_status").await,
925 + table_attempts: t("fediverse.queue.table_attempts").await,
926 + table_last_error: t("fediverse.queue.table_last_error").await,
927 + table_next_retry: t("fediverse.queue.table_next_retry").await,
928 + table_created_at: t("fediverse.queue.table_created_at").await,
929 + empty_title: t("fediverse.queue.empty_title").await,
930 + empty_body: t("fediverse.queue.empty_body").await,
931 + breadcrumb_home: t("breadcrumb.home").await,
932 + breadcrumb_admin: t("breadcrumb.admin").await,
933 + breadcrumb_self: t("fediverse.queue.breadcrumb").await,
934 + };
935 +
936 + Ok(template.into_response())
937 +}
938 +
939 +// ─────────────────────────────────────────────────────────────────────
940 +// /admin/fediverse/sessions_remote (RFC 0024 Slice C iter. 2)
941 +// ─────────────────────────────────────────────────────────────────────
942 +
943 +/// One row rendered into the sessions-remote table.
944 +#[derive(Debug, Clone)]
945 +pub struct RemoteSessionRowCtx {
946 + /// PK of the remote_identities row.
947 + pub id: String,
948 + /// Canonical AP id (`https://b.tld/users/alice`), truncated.
949 + pub ap_id: String,
950 + /// `@user@domain` recomposed for human display.
951 + pub handle: String,
952 + /// Display name advertised by the actor (or empty).
953 + pub display_name: String,
954 + /// Whether [`Self::display_name`] is non-empty.
955 + pub has_display_name: bool,
956 + /// Domain of the federated instance.
957 + pub domain: String,
958 + /// `last_fetched_at` formatted `YYYY-MM-DD HH:MM`.
959 + pub last_seen: String,
960 +}
961 +
962 +/// Form payload for `POST /admin/fediverse/sessions_remote/revoke`.
963 +#[derive(Debug, Deserialize)]
964 +pub struct RevokeRemoteForm {
965 + /// CSRF token from the hidden form field.
966 + pub csrf_token: String,
967 + /// PK of the remote_identities row to disable.
968 + pub id: String,
969 +}
970 +
971 +/// Form payload for `POST /admin/fediverse/sessions_remote/kill_domain`.
972 +#[derive(Debug, Deserialize)]
973 +pub struct KillDomainForm {
974 + /// CSRF token from the hidden form field.
975 + pub csrf_token: String,
976 + /// Domain whose remote_identities rows should be disabled in bulk.
977 + pub domain: String,
978 +}
979 +
980 +/// Askama template for `GET /admin/fediverse/sessions_remote`.
981 +#[derive(Template)]
982 +#[template(path = "admin/fediverse/sessions_remote.html")]
983 +#[allow(dead_code, missing_docs)]
984 +pub struct FediverseSessionsRemoteTemplate {
985 + pub page_title: String,
986 + pub csrf_token: String,
987 + pub app_name: String,
988 + pub app_version: String,
989 + pub app_domain: String,
990 + pub app_description: String,
991 + pub username: String,
992 + pub email: String,
993 + pub initials: String,
994 + pub avatar_color: String,
995 + pub roles_text: String,
996 + pub has_pending_password_change: bool,
997 + pub nav_items: Vec<NavItem>,
998 + pub user_menu_profile: String,
999 + pub user_menu_logout: String,
1000 +
1001 + pub saved_flash: bool,
1002 + pub rows: Vec<RemoteSessionRowCtx>,
1003 + pub has_rows: bool,
1004 + /// Distinct domains present in [`Self::rows`] for the kill-switch
1005 + /// `<select>`.
1006 + pub distinct_domains: Vec<String>,
1007 +
1008 + pub heading: String,
1009 + pub subtitle: String,
1010 + pub kill_switch_legend: String,
1011 + pub kill_switch_label: String,
1012 + pub kill_switch_button: String,
1013 + pub kill_switch_confirm_title: String,
1014 + pub kill_switch_confirm_body: String,
1015 + pub table_handle: String,
1016 + pub table_display_name: String,
1017 + pub table_domain: String,
1018 + pub table_last_seen: String,
1019 + pub table_actions: String,
1020 + pub revoke_button: String,
1021 + pub revoke_confirm_title: String,
1022 + pub revoke_confirm_body: String,
1023 + pub modal_cancel: String,
1024 + pub modal_confirm: String,
1025 + pub empty_title: String,
1026 + pub empty_body: String,
1027 + pub saved_toast: String,
1028 + pub breadcrumb_home: String,
1029 + pub breadcrumb_admin: String,
1030 + pub breadcrumb_self: String,
1031 +}
1032 +
1033 +rustwarden_core::impl_template_response!(FediverseSessionsRemoteTemplate);
1034 +
1035 +/// Render `GET /admin/fediverse/sessions_remote` — list active federated
1036 +/// identities (rows with `disabled_at IS NULL`).
1037 +pub async fn get_sessions_remote(
1038 + Extension(claims): Extension<Claims>,
1039 + Extension(i18n_service): Extension<Arc<I18nService>>,
1040 + Extension(locale): Extension<Locale>,
1041 + State(db): State<DatabaseConnection>,
1042 + session: Session,
1043 + axum::extract::Query(query): axum::extract::Query<SavedQuery>,
1044 +) -> Result<axum::response::Response, AppError> {
1045 + let trust = FediverseTrustService::new(db.clone());
1046 + if !trust.is_enabled().await? {
1047 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
1048 + }
1049 +
1050 + let locale_str = locale.as_str();
1051 + let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
1052 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
1053 +
1054 + let active_rows = remote_identity::Entity::find()
1055 + .filter(remote_identity::Column::DisabledAt.is_null())
1056 + .order_by_desc(remote_identity::Column::LastFetchedAt)
1057 + .limit(200)
1058 + .all(&db)
1059 + .await?;
1060 +
1061 + let rows: Vec<RemoteSessionRowCtx> = active_rows
1062 + .iter()
1063 + .map(|m| RemoteSessionRowCtx {
1064 + id: m.id.to_string(),
1065 + ap_id: truncate(&m.ap_id, 60),
1066 + handle: format!("@{}@{}", m.username, m.domain),
1067 + has_display_name: m
1068 + .display_name
1069 + .as_deref()
1070 + .is_some_and(|s| !s.is_empty()),
1071 + display_name: m.display_name.clone().unwrap_or_default(),
1072 + domain: m.domain.clone(),
1073 + last_seen: m.last_fetched_at.format("%Y-%m-%d %H:%M").to_string(),
1074 + })
1075 + .collect();
1076 + let has_rows = !rows.is_empty();
1077 +
1078 + let mut distinct_domains: Vec<String> = active_rows
1079 + .iter()
1080 + .map(|m| m.domain.clone())
1081 + .collect::<std::collections::BTreeSet<_>>()
1082 + .into_iter()
1083 + .collect();
1084 + distinct_domains.sort();
1085 +
1086 + let t = |k: &str| {
1087 + let i18n = i18n_service.clone();
1088 + let key = k.to_string();
1089 + let loc = locale_str.to_string();
1090 + async move { i18n.translate(&key, &loc).await }
1091 + };
1092 +
1093 + let template = FediverseSessionsRemoteTemplate {
1094 + page_title: t("fediverse.sessions_remote.title").await,
1095 + csrf_token,
1096 + app_name: base_context.app_name,
1097 + app_version: base_context.app_version,
1098 + app_domain: base_context.app_domain,
1099 + app_description: base_context.app_description,
1100 + username: base_context.username,
1101 + email: base_context.email,
1102 + initials: base_context.initials,
1103 + avatar_color: base_context.avatar_color,
1104 + roles_text: base_context.roles.join(", "),
1105 + has_pending_password_change: false,
1106 + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
1107 + .await,
1108 + user_menu_profile: t("user_menu.profile").await,
1109 + user_menu_logout: t("user_menu.logout").await,
1110 +
1111 + saved_flash: query.saved.unwrap_or(0) == 1,
1112 + rows,
1113 + has_rows,
1114 + distinct_domains,
1115 +
1116 + heading: t("fediverse.sessions_remote.heading").await,
1117 + subtitle: t("fediverse.sessions_remote.subtitle").await,
1118 + kill_switch_legend: t("fediverse.sessions_remote.kill_switch_legend").await,
1119 + kill_switch_label: t("fediverse.sessions_remote.kill_switch_label").await,
1120 + kill_switch_button: t("fediverse.sessions_remote.kill_switch_button").await,
1121 + kill_switch_confirm_title: t("fediverse.sessions_remote.kill_switch_confirm_title").await,
1122 + kill_switch_confirm_body: t("fediverse.sessions_remote.kill_switch_confirm_body").await,
1123 + table_handle: t("fediverse.sessions_remote.table_handle").await,
1124 + table_display_name: t("fediverse.sessions_remote.table_display_name").await,
1125 + table_domain: t("fediverse.sessions_remote.table_domain").await,
1126 + table_last_seen: t("fediverse.sessions_remote.table_last_seen").await,
1127 + table_actions: t("fediverse.sessions_remote.table_actions").await,
1128 + revoke_button: t("fediverse.sessions_remote.revoke_button").await,
1129 + revoke_confirm_title: t("fediverse.sessions_remote.revoke_confirm_title").await,
1130 + revoke_confirm_body: t("fediverse.sessions_remote.revoke_confirm_body").await,
1131 + modal_cancel: t("fediverse.sessions_remote.modal_cancel").await,
1132 + modal_confirm: t("fediverse.sessions_remote.modal_confirm").await,
1133 + empty_title: t("fediverse.sessions_remote.empty_title").await,
1134 + empty_body: t("fediverse.sessions_remote.empty_body").await,
1135 + saved_toast: t("fediverse.sessions_remote.saved_toast").await,
1136 + breadcrumb_home: t("breadcrumb.home").await,
1137 + breadcrumb_admin: t("breadcrumb.admin").await,
1138 + breadcrumb_self: t("fediverse.sessions_remote.breadcrumb").await,
1139 + };
1140 +
1141 + Ok(template.into_response())
1142 +}
1143 +
1144 +/// `POST /admin/fediverse/sessions_remote/revoke` — set `disabled_at`
1145 +/// on a single remote_identities row.
1146 +pub async fn revoke_remote_session(
1147 + State(db): State<DatabaseConnection>,
1148 + session: Session,
1149 + Form(form): Form<RevokeRemoteForm>,
1150 +) -> Result<axum::response::Response, AppError> {
1151 + csrf::validate_csrf_token(&session, &form.csrf_token).await?;
1152 +
1153 + let trust = FediverseTrustService::new(db.clone());
1154 + if !trust.is_enabled().await? {
1155 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
1156 + }
1157 +
1158 + let id = uuid::Uuid::parse_str(&form.id)
1159 + .map_err(|_| AppError::Validation("invalid remote identity id".into()))?;
1160 + let now = chrono::Utc::now();
1161 +
1162 + use sea_orm::sea_query::Expr;
1163 + remote_identity::Entity::update_many()
1164 + .col_expr(remote_identity::Column::DisabledAt, Expr::value(now))
1165 + .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now))
1166 + .filter(remote_identity::Column::Id.eq(id))
1167 + .exec(&db)
1168 + .await?;
1169 +
1170 + Ok(Redirect::to("/admin/fediverse/sessions_remote?saved=1").into_response())
1171 +}
1172 +
1173 +/// `POST /admin/fediverse/sessions_remote/kill_domain` — bulk disable
1174 +/// every remote_identities row from a given domain. Used as a panic
1175 +/// button when an entire federated instance is compromised.
1176 +pub async fn kill_domain(
1177 + State(db): State<DatabaseConnection>,
1178 + session: Session,
1179 + Form(form): Form<KillDomainForm>,
1180 +) -> Result<axum::response::Response, AppError> {
1181 + csrf::validate_csrf_token(&session, &form.csrf_token).await?;
1182 +
1183 + let trust = FediverseTrustService::new(db.clone());
1184 + if !trust.is_enabled().await? {
1185 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
1186 + }
1187 +
1188 + let domain = form.domain.trim().to_ascii_lowercase();
1189 + if domain.is_empty() {
1190 + return Err(AppError::Validation("domain must not be empty".into()));
1191 + }
1192 + let now = chrono::Utc::now();
1193 +
1194 + use sea_orm::sea_query::Expr;
1195 + remote_identity::Entity::update_many()
1196 + .col_expr(remote_identity::Column::DisabledAt, Expr::value(now))
1197 + .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now))
1198 + .filter(remote_identity::Column::Domain.eq(domain))
1199 + .filter(remote_identity::Column::DisabledAt.is_null())
1200 + .exec(&db)
1201 + .await?;
1202 +
1203 + Ok(Redirect::to("/admin/fediverse/sessions_remote?saved=1").into_response())
1204 +}
1205 +
1206 +// ─────────────────────────────────────────────────────────────────────
1207 +// /admin/fediverse/nodeinfo (RFC 0024 Slice C iter. 3)
1208 +// ─────────────────────────────────────────────────────────────────────
1209 +
1210 +/// Askama template for `GET /admin/fediverse/nodeinfo`.
1211 +#[derive(Template)]
1212 +#[template(path = "admin/fediverse/nodeinfo.html")]
1213 +#[allow(dead_code, missing_docs)]
1214 +pub struct FediverseNodeInfoTemplate {
1215 + pub page_title: String,
1216 + pub csrf_token: String,
1217 + pub app_name: String,
1218 + pub app_version: String,
1219 + pub app_domain: String,
1220 + pub app_description: String,
1221 + pub username: String,
1222 + pub email: String,
1223 + pub initials: String,
1224 + pub avatar_color: String,
1225 + pub roles_text: String,
1226 + pub has_pending_password_change: bool,
1227 + pub nav_items: Vec<NavItem>,
1228 + pub user_menu_profile: String,
1229 + pub user_menu_logout: String,
1230 +
1231 + // Software
1232 + pub software_name: String,
1233 + pub software_version: String,
1234 + pub fediverse_domain: String,
1235 + // Federation runtime config
1236 + pub fediverse_enabled_label: String,
1237 + pub trust_mode: String,
1238 + pub interop_scope: String,
1239 + pub authorized_fetch: bool,
1240 + // User counts
1241 + pub users_total: u64,
1242 + pub users_active_month: u64,
1243 + pub users_active_halfyear: u64,
1244 + pub remote_identities_active: u64,
1245 + pub remote_distinct_domains: u64,
1246 +
1247 + // i18n
1248 + pub heading: String,
1249 + pub subtitle: String,
1250 + pub card_software: String,
1251 + pub card_runtime: String,
1252 + pub card_users: String,
1253 + pub card_remotes: String,
1254 + pub label_name: String,
1255 + pub label_version: String,
1256 + pub label_domain: String,
1257 + pub label_enabled: String,
1258 + pub label_trust_mode: String,
1259 + pub label_interop_scope: String,
1260 + pub label_authorized_fetch: String,
1261 + pub label_users_total: String,
1262 + pub label_users_active_month: String,
1263 + pub label_users_active_halfyear: String,
1264 + pub label_remote_identities_active: String,
1265 + pub label_remote_distinct_domains: String,
1266 + pub yes_label: String,
1267 + pub no_label: String,
1268 + pub breadcrumb_home: String,
1269 + pub breadcrumb_admin: String,
1270 + pub breadcrumb_self: String,
1271 +}
1272 +
1273 +rustwarden_core::impl_template_response!(FediverseNodeInfoTemplate);
1274 +
1275 +/// Render `GET /admin/fediverse/nodeinfo` — humanised stats panel.
1276 +///
1277 +/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false`.
1278 +pub async fn get_nodeinfo(
1279 + Extension(claims): Extension<Claims>,
1280 + Extension(i18n_service): Extension<Arc<I18nService>>,
1281 + Extension(locale): Extension<Locale>,
1282 + State(db): State<DatabaseConnection>,
1283 + session: Session,
1284 +) -> Result<axum::response::Response, AppError> {
1285 + let trust = FediverseTrustService::new(db.clone());
1286 + if !trust.is_enabled().await? {
1287 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
1288 + }
1289 +
1290 + let locale_str = locale.as_str();
1291 + let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
1292 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
1293 +
1294 + // Federation runtime config (read straight from app_settings).
1295 + let trust_mode = match trust.trust_mode().await? {
1296 + TrustMode::Allowlist => "allowlist".to_string(),
1297 + TrustMode::OpenWithBlocklist => "open_with_blocklist".to_string(),
1298 + };
1299 + let interop_scope = AppSettingsService::get_setting(&db, "fediverse_interop_scope")
1300 + .await?
1301 + .map(|s| s.value)
1302 + .unwrap_or_else(|| "rustwarden_only".to_string());
1303 + let authorized_fetch =
1304 + AppSettingsService::get_bool(&db, "fediverse_authorized_fetch", false)
1305 + .await?;
1306 +
1307 + // User counts (local users).
1308 + let users_total = user_model::Entity::find().count(&db).await.unwrap_or(0);
1309 + let now = chrono::Utc::now();
1310 + let users_active_month = user_model::Entity::find()
1311 + .filter(user_model::Column::LastLoginAt.gte(now - chrono::Duration::days(30)))
1312 + .count(&db)
1313 + .await
1314 + .unwrap_or(0);
1315 + let users_active_halfyear = user_model::Entity::find()
1316 + .filter(user_model::Column::LastLoginAt.gte(now - chrono::Duration::days(180)))
1317 + .count(&db)
1318 + .await
1319 + .unwrap_or(0);
1320 +
1321 + // Remote identities (active = disabled_at IS NULL).
1322 + let remote_identities_active = remote_identity::Entity::find()
1323 + .filter(remote_identity::Column::DisabledAt.is_null())
1324 + .count(&db)
1325 + .await
1326 + .unwrap_or(0);
1327 +
1328 + let remote_rows = remote_identity::Entity::find()
1329 + .filter(remote_identity::Column::DisabledAt.is_null())
1330 + .all(&db)
1331 + .await
1332 + .unwrap_or_default();
1333 + let remote_distinct_domains = remote_rows
1334 + .iter()
1335 + .map(|m| m.domain.clone())
1336 + .collect::<std::collections::BTreeSet<_>>()
1337 + .len() as u64;
1338 +
1339 + let fediverse_domain = std::env::var("FEDIVERSE_DOMAIN").unwrap_or_else(|_| "—".to_string());
1340 +
1341 + let t = |k: &str| {
1342 + let i18n = i18n_service.clone();
1343 + let key = k.to_string();
1344 + let loc = locale_str.to_string();
1345 + async move { i18n.translate(&key, &loc).await }
1346 + };
1347 +
1348 + let template = FediverseNodeInfoTemplate {
1349 + page_title: t("fediverse_nodeinfo.title").await,
1350 + csrf_token,
1351 + app_name: base_context.app_name,
1352 + app_version: base_context.app_version,
1353 + app_domain: base_context.app_domain,
1354 + app_description: base_context.app_description,
1355 + username: base_context.username,
1356 + email: base_context.email,
1357 + initials: base_context.initials,
1358 + avatar_color: base_context.avatar_color,
1359 + roles_text: base_context.roles.join(", "),
1360 + has_pending_password_change: false,
1361 + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
1362 + .await,
1363 + user_menu_profile: t("user_menu.profile").await,
1364 + user_menu_logout: t("user_menu.logout").await,
1365 +
1366 + software_name: "rustwarden".to_string(),
1367 + software_version: env!("CARGO_PKG_VERSION").to_string(),
1368 + fediverse_domain,
1369 + fediverse_enabled_label: t("common.yes").await,
1370 + trust_mode,
1371 + interop_scope,
1372 + authorized_fetch,
1373 + users_total,
1374 + users_active_month,
1375 + users_active_halfyear,
1376 + remote_identities_active,
1377 + remote_distinct_domains,
1378 +
1379 + heading: t("fediverse_nodeinfo.heading").await,
1380 + subtitle: t("fediverse_nodeinfo.subtitle").await,
1381 + card_software: t("fediverse_nodeinfo.card_software").await,
1382 + card_runtime: t("fediverse_nodeinfo.card_runtime").await,
1383 + card_users: t("fediverse_nodeinfo.card_users").await,
1384 + card_remotes: t("fediverse_nodeinfo.card_remotes").await,
1385 + label_name: t("fediverse_nodeinfo.label_name").await,
1386 + label_version: t("fediverse_nodeinfo.label_version").await,
1387 + label_domain: t("fediverse_nodeinfo.label_domain").await,
1388 + label_enabled: t("fediverse_nodeinfo.label_enabled").await,
1389 + label_trust_mode: t("fediverse_nodeinfo.label_trust_mode").await,
1390 + label_interop_scope: t("fediverse_nodeinfo.label_interop_scope").await,
1391 + label_authorized_fetch: t("fediverse_nodeinfo.label_authorized_fetch").await,
1392 + label_users_total: t("fediverse_nodeinfo.label_users_total").await,
1393 + label_users_active_month: t("fediverse_nodeinfo.label_users_active_month").await,
1394 + label_users_active_halfyear: t("fediverse_nodeinfo.label_users_active_halfyear").await,
1395 + label_remote_identities_active: t("fediverse_nodeinfo.label_remote_identities_active")
1396 + .await,
1397 + label_remote_distinct_domains: t("fediverse_nodeinfo.label_remote_distinct_domains").await,
1398 + yes_label: t("common.yes").await,
1399 + no_label: t("common.no").await,
1400 + breadcrumb_home: t("breadcrumb.home").await,
1401 + breadcrumb_admin: t("breadcrumb.admin").await,
1402 + breadcrumb_self: t("fediverse_nodeinfo.breadcrumb").await,
1403 + };
1404 +
1405 + Ok(template.into_response())
1406 +}
1407 +
1408 +// ─────────────────────────────────────────────────────────────────────
1409 +// /admin/fediverse/invites/{team_id} (RFC 0024 Slice C iter. 4)
1410 +// ─────────────────────────────────────────────────────────────────────
1411 +
1412 +/// One row rendered into the team invites table.
1413 +#[derive(Debug, Clone)]
1414 +pub struct TeamInviteRowCtx {
1415 + /// PK of the invite row.
1416 + pub id: String,
1417 + /// `@user@domain` handle.
1418 + pub invited_handle: String,
1419 + /// Wire form: `owner` / `admin` / `member`.
1420 + pub role: String,
1421 + /// Wire form: `pending` / `accepted` / `rejected` / `revoked` / `expired`.
1422 + pub status: String,
1423 + /// `created_at` formatted `YYYY-MM-DD HH:MM`.
1424 + pub created_at: String,
1425 +}
1426 +
1427 +/// Form payload for `POST /admin/fediverse/invites/{team_id}/add`.
1428 +#[derive(Debug, Deserialize)]
1429 +pub struct CreateTeamInviteForm {
1430 + /// CSRF token from the hidden form field.
1431 + pub csrf_token: String,
1432 + /// `@user@domain` handle to invite.
1433 + pub handle: String,
1434 + /// `owner` / `admin` / `member`.
1435 + pub role: String,
1436 +}
1437 +
1438 +/// Form payload for `POST /admin/fediverse/invites/{team_id}/revoke`.
1439 +#[derive(Debug, Deserialize)]
1440 +pub struct RevokeInviteForm {
1441 + /// CSRF token from the hidden form field.
1442 + pub csrf_token: String,
1443 + /// PK of the invite row to revoke.
1444 + pub id: String,
1445 +}
1446 +
1447 +/// Askama template for `GET /admin/fediverse/invites/{team_id}`.
1448 +#[derive(Template)]
1449 +#[template(path = "admin/fediverse/team_invites.html")]
1450 +#[allow(dead_code, missing_docs)]
1451 +pub struct FediverseTeamInvitesTemplate {
1452 + pub page_title: String,
1453 + pub csrf_token: String,
1454 + pub app_name: String,
1455 + pub app_version: String,
1456 + pub app_domain: String,
1457 + pub app_description: String,
1458 + pub username: String,
1459 + pub email: String,
1460 + pub initials: String,
1461 + pub avatar_color: String,
1462 + pub roles_text: String,
1463 + pub has_pending_password_change: bool,
1464 + pub nav_items: Vec<NavItem>,
1465 + pub user_menu_profile: String,
1466 + pub user_menu_logout: String,
1467 +
1468 + pub saved_flash: bool,
1469 + /// Team name for the page heading.
1470 + pub team_name: String,
1471 + /// Team UUID (for form actions).
1472 + pub team_id: String,
1473 + pub rows: Vec<TeamInviteRowCtx>,
1474 + pub has_rows: bool,
1475 +
1476 + pub heading: String,
1477 + pub subtitle: String,
1478 + pub add_form_legend: String,
1479 + pub handle_label: String,
1480 + pub handle_placeholder: String,
1481 + pub role_label: String,
1482 + pub role_member: String,
1483 + pub role_admin: String,
1484 + pub role_owner: String,
1485 + pub add_button: String,
1486 + pub table_handle: String,
1487 + pub table_role: String,
1488 + pub table_status: String,
1489 + pub table_created_at: String,
1490 + pub table_actions: String,
1491 + pub revoke_button: String,
1492 + pub revoke_confirm_title: String,
1493 + pub revoke_confirm_body: String,
1494 + pub modal_cancel: String,
1495 + pub modal_confirm: String,
1496 + pub empty_title: String,
1497 + pub empty_body: String,
1498 + pub saved_toast: String,
1499 + pub breadcrumb_home: String,
1500 + pub breadcrumb_admin: String,
1501 + pub breadcrumb_self: String,
1502 +}
1503 +
1504 +rustwarden_core::impl_template_response!(FediverseTeamInvitesTemplate);
1505 +
1506 +/// Default TTL for team invitations (RFC 0009 §6).
1507 +const INVITE_TTL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
1508 +
1509 +/// `GET /admin/fediverse/invites/{team_id}` — list pending invites.
1510 +pub async fn get_team_invites(
1511 + Extension(claims): Extension<Claims>,
1512 + Extension(i18n_service): Extension<Arc<I18nService>>,
1513 + Extension(locale): Extension<Locale>,
1514 + State(db): State<DatabaseConnection>,
1515 + session: Session,
1516 + axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>,
1517 + axum::extract::Query(query): axum::extract::Query<SavedQuery>,
1518 +) -> Result<axum::response::Response, AppError> {
1519 + let trust = FediverseTrustService::new(db.clone());
1520 + if !trust.is_enabled().await? {
1521 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
1522 + }
1523 +
1524 + // Verify team exists; otherwise 404.
1525 + let Some(team_row) = team::Entity::find_by_id(team_id).one(&db).await? else {
1526 + return Ok((axum::http::StatusCode::NOT_FOUND, "Team not found").into_response());
1527 + };
1528 +
1529 + let locale_str = locale.as_str();
1530 + let base_context = build_base_context_with_claims(&session, &db, &claims).await?;
1531 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
1532 +
1533 + // Show all statuses for this team (not just pending) so the admin
1534 + // sees the historical decisions. Order by created_at desc.
1535 + let invite_rows = fediverse_pending_invite::Entity::find()
1536 + .filter(fediverse_pending_invite::Column::TeamId.eq(team_id))
1537 + .order_by_desc(fediverse_pending_invite::Column::CreatedAt)
1538 + .limit(100)
1539 + .all(&db)
1540 + .await?;
1541 +
1542 + let rows: Vec<TeamInviteRowCtx> = invite_rows
1543 + .into_iter()
1544 + .map(|m| TeamInviteRowCtx {
1545 + id: m.id.to_string(),
1546 + invited_handle: m.invited_handle,
1547 + role: m.role,
1548 + status: m.status,
1549 + created_at: m.created_at.format("%Y-%m-%d %H:%M").to_string(),
1550 + })
1551 + .collect();
1552 + let has_rows = !rows.is_empty();
1553 +
1554 + let t = |k: &str| {
1555 + let i18n = i18n_service.clone();
1556 + let key = k.to_string();
1557 + let loc = locale_str.to_string();
1558 + async move { i18n.translate(&key, &loc).await }
1559 + };
1560 +
1561 + let template = FediverseTeamInvitesTemplate {
1562 + page_title: t("fediverse_invites.title").await,
1563 + csrf_token,
1564 + app_name: base_context.app_name,
1565 + app_version: base_context.app_version,
1566 + app_domain: base_context.app_domain,
1567 + app_description: base_context.app_description,
1568 + username: base_context.username,
1569 + email: base_context.email,
1570 + initials: base_context.initials,
1571 + avatar_color: base_context.avatar_color,
1572 + roles_text: base_context.roles.join(", "),
1573 + has_pending_password_change: false,
1574 + nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db)
1575 + .await,
1576 + user_menu_profile: t("user_menu.profile").await,
1577 + user_menu_logout: t("user_menu.logout").await,
1578 +
1579 + saved_flash: query.saved.unwrap_or(0) == 1,
1580 + team_name: team_row.name,
1581 + team_id: team_id.to_string(),
1582 + rows,
1583 + has_rows,
1584 +
1585 + heading: t("fediverse_invites.heading").await,
1586 + subtitle: t("fediverse_invites.subtitle").await,
1587 + add_form_legend: t("fediverse_invites.add_legend").await,
1588 + handle_label: t("fediverse_invites.handle_label").await,
1589 + handle_placeholder: t("fediverse_invites.handle_placeholder").await,
1590 + role_label: t("fediverse_invites.role_label").await,
1591 + role_member: t("fediverse_invites.role_member").await,
1592 + role_admin: t("fediverse_invites.role_admin").await,
1593 + role_owner: t("fediverse_invites.role_owner").await,
1594 + add_button: t("fediverse_invites.add_button").await,
1595 + table_handle: t("fediverse_invites.table_handle").await,
1596 + table_role: t("fediverse_invites.table_role").await,
1597 + table_status: t("fediverse_invites.table_status").await,
1598 + table_created_at: t("fediverse_invites.table_created_at").await,
1599 + table_actions: t("fediverse_invites.table_actions").await,
1600 + revoke_button: t("fediverse_invites.revoke_button").await,
1601 + revoke_confirm_title: t("fediverse_invites.revoke_confirm_title").await,
1602 + revoke_confirm_body: t("fediverse_invites.revoke_confirm_body").await,
1603 + modal_cancel: t("fediverse_invites.modal_cancel").await,
1604 + modal_confirm: t("fediverse_invites.modal_confirm").await,
1605 + empty_title: t("fediverse_invites.empty_title").await,
1606 + empty_body: t("fediverse_invites.empty_body").await,
1607 + saved_toast: t("fediverse_invites.saved_toast").await,
1608 + breadcrumb_home: t("breadcrumb.home").await,
1609 + breadcrumb_admin: t("breadcrumb.admin").await,
1610 + breadcrumb_self: t("fediverse_invites.breadcrumb").await,
1611 + };
1612 +
1613 + Ok(template.into_response())
1614 +}
1615 +
1616 +/// `POST /admin/fediverse/invites/{team_id}/add` — create a pending invite.
1617 +pub async fn create_team_invite(
1618 + Extension(claims): Extension<Claims>,
1619 + State(db): State<DatabaseConnection>,
1620 + session: Session,
1621 + axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>,
1622 + Form(form): Form<CreateTeamInviteForm>,
1623 +) -> Result<axum::response::Response, AppError> {
1624 + csrf::validate_csrf_token(&session, &form.csrf_token).await?;
1625 +
1626 + let trust = FediverseTrustService::new(db.clone());
1627 + if !trust.is_enabled().await? {
1628 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
1629 + }
1630 +
1631 + let handle = form.handle.trim();
1632 + if handle.is_empty() || !handle.contains('@') {
1633 + return Err(AppError::Validation(
1634 + "handle must be of form @user@domain".into(),
1635 + ));
1636 + }
1637 + let role = TeamRole::parse(&form.role).ok_or_else(|| {
1638 + AppError::Validation(format!("invalid role: {}", form.role))
1639 + })?;
1640 +
1641 + FediverseInviteService::invite_remote(&db, team_id, handle, role, claims.sub, INVITE_TTL)
1642 + .await?;
1643 +
1644 + Ok(Redirect::to(&format!(
1645 + "/admin/fediverse/invites/{team_id}?saved=1"
1646 + ))
1647 + .into_response())
1648 +}
1649 +
1650 +/// `POST /admin/fediverse/invites/{team_id}/revoke` — revoke an invite.
1651 +pub async fn revoke_team_invite(
1652 + State(db): State<DatabaseConnection>,
1653 + session: Session,
1654 + axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>,
1655 + Form(form): Form<RevokeInviteForm>,
1656 +) -> Result<axum::response::Response, AppError> {
1657 + csrf::validate_csrf_token(&session, &form.csrf_token).await?;
1658 +
1659 + let trust = FediverseTrustService::new(db.clone());
1660 + if !trust.is_enabled().await? {
1661 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
1662 + }
1663 +
1664 + let invite_id = uuid::Uuid::parse_str(&form.id)
1665 + .map_err(|_| AppError::Validation("invalid invite id".into()))?;
1666 +
1667 + // Touch unused-import guard for InviteStatus (kept exported).
1668 + let _ = InviteStatus::Pending;
1669 +
1670 + FediverseInviteService::revoke(&db, invite_id).await?;
1671 +
1672 + Ok(Redirect::to(&format!(
1673 + "/admin/fediverse/invites/{team_id}?saved=1"
1674 + ))
1675 + .into_response())
1676 +}
A crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs
+982 -0
@@ -0,0 +1,982 @@
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_core::services::fediverse_auth_service::{
31 + ChallengeDecision, ChallengeStatus, FediverseAuthService, InitiateRequest,
32 +};
33 +use rustwarden_core::services::fediverse_invite_service::FediverseInviteService;
34 +use rustwarden_core::models::fediverse_pending_invite;
35 +use rustwarden_core::services::fediverse_trust_service::FediverseTrustService;
36 +
37 +use rustwarden_core::models::fediverse_auth_challenge;
38 +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
39 +
40 +/// Default challenge TTL for the AP-native auth flow (RFC 0006 §6).
41 +/// Operator override via `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` is honoured
42 +/// by the service layer separately; here we only provide a UI default
43 +/// to hand into [`FediverseAuthService::initiate`].
44 +const CHALLENGE_TTL: Duration = Duration::from_secs(5 * 60);
45 +
46 +/// Whitelist of accepted `?reason=` values. Any other input falls back
47 +/// to [`ErrorReason::Unknown`] which renders a generic message — keeps
48 +/// the URL safe to share without leaking internal status names.
49 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50 +pub enum ErrorReason {
51 + /// `Accept{AuthRequest}` not received within the challenge TTL.
52 + Timeout,
53 + /// IdP explicitly rejected the auth request.
54 + Rejected,
55 + /// Domain not in the allowlist (or in the blocklist).
56 + Untrusted,
57 + /// HTTP signature on the IdP response did not verify.
58 + InvalidSignature,
59 + /// Anything else (renders the generic catch-all message).
60 + Unknown,
61 +}
62 +
63 +impl ErrorReason {
64 + /// Parse the `?reason=` query value into an enum. Trims, lowercases.
65 + pub fn parse(raw: &str) -> Self {
66 + match raw.trim().to_ascii_lowercase().as_str() {
67 + "timeout" => Self::Timeout,
68 + "rejected" => Self::Rejected,
69 + "untrusted" => Self::Untrusted,
70 + "invalid_signature" | "invalid_sig" => Self::InvalidSignature,
71 + _ => Self::Unknown,
72 + }
73 + }
74 +
75 + /// i18n key suffix for the heading + body copy.
76 + pub fn i18n_key(self) -> &'static str {
77 + match self {
78 + Self::Timeout => "timeout",
79 + Self::Rejected => "rejected",
80 + Self::Untrusted => "untrusted",
81 + Self::InvalidSignature => "invalid_signature",
82 + Self::Unknown => "unknown",
83 + }
84 + }
85 +
86 + /// Stable wire form (echoed back into the template for icon styling).
87 + pub fn as_str(self) -> &'static str {
88 + self.i18n_key()
89 + }
90 +}
91 +
92 +/// `?reason=` query parameter for `GET /login/fediverse/error`.
93 +#[derive(Debug, Deserialize)]
94 +pub struct ErrorQuery {
95 + /// One of `timeout`, `rejected`, `untrusted`, `invalid_signature`.
96 + /// Anything else renders the generic message.
97 + pub reason: Option<String>,
98 +}
99 +
100 +/// Askama template for `GET /login/fediverse/error`.
101 +#[derive(Template)]
102 +#[template(path = "fediverse/error_unauthorized.html")]
103 +#[allow(dead_code)]
104 +pub struct FediverseAuthErrorTemplate {
105 + /// IETF locale tag (`fr`, `en`, …) for `<html lang>`.
106 + pub current_locale: String,
107 + /// Browser tab title.
108 + pub page_title: String,
109 + /// Meta-description copy.
110 + pub subtitle: String,
111 + /// Wire form of the error reason — used by the template to pick an
112 + /// icon (timeout / invalid_signature / generic alert).
113 + pub reason: String,
114 + /// i18n-translated H1.
115 + pub heading: String,
116 + /// i18n-translated body paragraph.
117 + pub body: String,
118 + /// Primary CTA label.
119 + pub back_to_login: String,
120 +}
121 +
122 +rustwarden_core::impl_template_response!(FediverseAuthErrorTemplate);
123 +
124 +/// `GET /login/fediverse/error?reason=…` — landing page after a failed
125 +/// auth attempt.
126 +///
127 +/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` so
128 +/// the page is invisible while the feature is off.
129 +pub async fn error(
130 + Extension(i18n_service): Extension<Arc<I18nService>>,
131 + Extension(locale): Extension<Locale>,
132 + State(db): State<DatabaseConnection>,
133 + Query(query): Query<ErrorQuery>,
134 +) -> Result<axum::response::Response, AppError> {
135 + let trust = FediverseTrustService::new(db.clone());
136 + if !trust.is_enabled().await? {
137 + return Ok((
138 + axum::http::StatusCode::NOT_FOUND,
139 + "Federation disabled",
140 + )
141 + .into_response());
142 + }
143 +
144 + let reason = ErrorReason::parse(query.reason.as_deref().unwrap_or(""));
145 + let locale_str = locale.as_str();
146 + let key = reason.i18n_key();
147 +
148 + let heading_key = format!("fediverse.auth.error.{key}.title");
149 + let body_key = format!("fediverse.auth.error.{key}.body");
150 +
151 + let template = FediverseAuthErrorTemplate {
152 + current_locale: locale_str.to_string(),
153 + page_title: i18n_service
154 + .translate("fediverse.auth.error.page_title", locale_str)
155 + .await,
156 + subtitle: i18n_service
157 + .translate("fediverse.auth.error.subtitle", locale_str)
158 + .await,
159 + reason: reason.as_str().to_string(),
160 + heading: i18n_service.translate(&heading_key, locale_str).await,
161 + body: i18n_service.translate(&body_key, locale_str).await,
162 + back_to_login: i18n_service
163 + .translate("fediverse.auth.error.back_to_login", locale_str)
164 + .await,
165 + };
166 +
167 + Ok(template.into_response())
168 +}
169 +
170 +// ─────────────────────────────────────────────────────────────────────
171 +// /login/fediverse — login form + initiate (RFC 0023 Slice B iter. 2)
172 +// ─────────────────────────────────────────────────────────────────────
173 +
174 +/// Form payload for `POST /login/fediverse`.
175 +#[derive(Debug, Deserialize)]
176 +pub struct LoginInitiateForm {
177 + /// CSRF token from the hidden form field.
178 + pub csrf_token: String,
179 + /// `@user@domain` handle the user typed.
180 + pub handle: String,
181 +}
182 +
183 +/// Askama template for `GET /login/fediverse`.
184 +#[derive(Template)]
185 +#[template(path = "fediverse/login_form.html")]
186 +#[allow(dead_code, missing_docs)]
187 +pub struct FediverseLoginFormTemplate {
188 + pub current_locale: String,
189 + pub page_title: String,
190 + pub subtitle: String,
191 + pub csrf_token: String,
192 + pub heading: String,
193 + pub field_label: String,
194 + pub field_placeholder: String,
195 + pub field_help: String,
196 + pub submit_button: String,
197 + pub back_to_login: String,
198 +}
199 +
200 +rustwarden_core::impl_template_response!(FediverseLoginFormTemplate);
201 +
202 +/// `GET /login/fediverse` — render the AP-native login form.
203 +///
204 +/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false`.
205 +pub async fn login_form(
206 + Extension(i18n_service): Extension<Arc<I18nService>>,
207 + Extension(locale): Extension<Locale>,
208 + State(db): State<DatabaseConnection>,
209 + session: Session,
210 +) -> Result<axum::response::Response, AppError> {
211 + let trust = FediverseTrustService::new(db.clone());
212 + if !trust.is_enabled().await? {
213 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
214 + }
215 +
216 + let locale_str = locale.as_str();
217 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
218 +
219 + let template = FediverseLoginFormTemplate {
220 + current_locale: locale_str.to_string(),
221 + page_title: i18n_service
222 + .translate("fediverse.auth.login.page_title", locale_str)
223 + .await,
224 + subtitle: i18n_service
225 + .translate("fediverse.auth.login.subtitle", locale_str)
226 + .await,
227 + csrf_token,
228 + heading: i18n_service
229 + .translate("fediverse.auth.login.heading", locale_str)
230 + .await,
231 + field_label: i18n_service
232 + .translate("fediverse.auth.login.field_label", locale_str)
233 + .await,
234 + field_placeholder: i18n_service
235 + .translate("fediverse.auth.login.field_placeholder", locale_str)
236 + .await,
237 + field_help: i18n_service
238 + .translate("fediverse.auth.login.field_help", locale_str)
239 + .await,
240 + submit_button: i18n_service
241 + .translate("fediverse.auth.login.submit", locale_str)
242 + .await,
243 + back_to_login: i18n_service
244 + .translate("fediverse.auth.login.back_to_login", locale_str)
245 + .await,
246 + };
247 +
248 + Ok(template.into_response())
249 +}
250 +
251 +/// Extract the `domain` part from a `@user@domain` (or `user@domain`)
252 +/// handle. Returns `None` on malformed input. Lowercased.
253 +pub fn extract_handle_domain(handle: &str) -> Option<String> {
254 + let trimmed = handle.trim().trim_start_matches('@');
255 + let (_, domain) = trimmed.split_once('@')?;
256 + let domain = domain.trim();
257 + if domain.is_empty() {
258 + return None;
259 + }
260 + Some(domain.to_ascii_lowercase())
261 +}
262 +
263 +/// `POST /login/fediverse` — start an AP-native login.
264 +///
265 +/// Pipeline:
266 +/// 1. Validate CSRF.
267 +/// 2. Reject if `fediverse_enabled = false` (404).
268 +/// 3. Parse handle ⇒ domain ; reject malformed via `error?reason=unknown`.
269 +/// 4. Check `FediverseTrustService::is_domain_allowed(domain)` ;
270 +/// redirect to `error?reason=untrusted` if denied.
271 +/// 5. Call `FediverseAuthService::initiate` ; on `Ok` redirect to
272 +/// `/login/fediverse/waiting/{id}` (page delivered iter. 3).
273 +/// 6. On any other error, redirect to `error?reason=unknown`.
274 +pub async fn login_initiate(
275 + State(db): State<DatabaseConnection>,
276 + session: Session,
277 + Form(form): Form<LoginInitiateForm>,
278 +) -> Result<axum::response::Response, AppError> {
279 + csrf::validate_csrf_token(&session, &form.csrf_token).await?;
280 +
281 + let trust = FediverseTrustService::new(db.clone());
282 + if !trust.is_enabled().await? {
283 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
284 + }
285 +
286 + let Some(domain) = extract_handle_domain(&form.handle) else {
287 + return Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response());
288 + };
289 +
290 + if !trust.is_domain_allowed(&domain).await? {
291 + return Ok(Redirect::to("/login/fediverse/error?reason=untrusted").into_response());
292 + }
293 +
294 + let req = InitiateRequest {
295 + handle: form.handle.trim().to_string(),
296 + redirect_url: None,
297 + };
298 + match FediverseAuthService::initiate(&db, req, CHALLENGE_TTL).await {
299 + Ok(challenge) => Ok(Redirect::to(&format!(
300 + "/login/fediverse/waiting/{}",
301 + challenge.id
302 + ))
303 + .into_response()),
304 + Err(AppError::Validation(_)) => {
305 + Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response())
306 + }
307 + Err(err) => {
308 + tracing::error!("FediverseAuthService::initiate failed: {err}");
309 + Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response())
310 + }
311 + }
312 +}
313 +
314 +// ─────────────────────────────────────────────────────────────────────
315 +// /login/fediverse/waiting/{id} — page d'attente + SSE (iter. 3)
316 +// ─────────────────────────────────────────────────────────────────────
317 +
318 +/// Askama template for `GET /login/fediverse/waiting/{challenge_id}`.
319 +#[derive(Template)]
320 +#[template(path = "fediverse/login_waiting.html")]
321 +#[allow(dead_code, missing_docs)]
322 +pub struct FediverseLoginWaitingTemplate {
323 + pub current_locale: String,
324 + pub page_title: String,
325 + pub subtitle: String,
326 + pub heading: String,
327 + pub explainer: String,
328 + pub cancel_button: String,
329 + /// Challenge UUID — embedded in the JS so the EventSource opens the
330 + /// correct SSE stream URL.
331 + pub challenge_id: String,
332 +}
333 +
334 +rustwarden_core::impl_template_response!(FediverseLoginWaitingTemplate);
335 +
336 +/// `GET /login/fediverse/waiting/{challenge_id}` — render the waiting
337 +/// page that opens an `EventSource` against the SSE endpoint below.
338 +pub async fn waiting(
339 + Extension(i18n_service): Extension<Arc<I18nService>>,
340 + Extension(locale): Extension<Locale>,
341 + State(db): State<DatabaseConnection>,
342 + Path(challenge_id): Path<Uuid>,
343 +) -> Result<axum::response::Response, AppError> {
344 + let trust = FediverseTrustService::new(db.clone());
345 + if !trust.is_enabled().await? {
346 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
347 + }
348 +
349 + // Verify the challenge exists; otherwise show the error page rather
350 + // than a stale spinner.
351 + if FediverseAuthService::status(&db, challenge_id).await.is_err() {
352 + return Ok(Redirect::to("/login/fediverse/error?reason=unknown").into_response());
353 + }
354 +
355 + let locale_str = locale.as_str();
356 + let template = FediverseLoginWaitingTemplate {
357 + current_locale: locale_str.to_string(),
358 + page_title: i18n_service
359 + .translate("fediverse.auth.waiting.page_title", locale_str)
360 + .await,
361 + subtitle: i18n_service
362 + .translate("fediverse.auth.waiting.subtitle", locale_str)
363 + .await,
364 + heading: i18n_service
365 + .translate("fediverse.auth.waiting.heading", locale_str)
366 + .await,
367 + explainer: i18n_service
368 + .translate("fediverse.auth.waiting.explainer", locale_str)
369 + .await,
370 + cancel_button: i18n_service
371 + .translate("fediverse.auth.waiting.cancel", locale_str)
372 + .await,
373 + challenge_id: challenge_id.to_string(),
374 + };
375 +
376 + Ok(template.into_response())
377 +}
378 +
379 +/// Polling cadence between two DB reads in [`sse_status`]. Two seconds
380 +/// is the master plan §10 default — small enough that the user
381 +/// perceives "live", large enough to keep the DB load negligible.
382 +const SSE_POLL_INTERVAL: Duration = Duration::from_secs(2);
383 +
384 +/// Maximum number of polls before the SSE stream gives up and closes.
385 +/// `5 min / 2 s = 150` matches the challenge TTL exactly so a never-
386 +/// answered challenge does not keep the connection alive forever.
387 +const SSE_MAX_POLLS: u32 = 150;
388 +
389 +/// `GET /login/fediverse/sse/{challenge_id}` — SSE polling endpoint.
390 +///
391 +/// Emits one `event: status` per poll with payload
392 +/// `{"state":"pending|approved|rejected|expired|consumed"}`. Stops after
393 +/// the first terminal status or [`SSE_MAX_POLLS`] iterations, whichever
394 +/// comes first. The endpoint is **public** — the challenge UUID is
395 +/// itself the unguessable bearer (256 bits of entropy, RFC 0006).
396 +pub async fn sse_status(
397 + State(db): State<DatabaseConnection>,
398 + Path(challenge_id): Path<Uuid>,
399 +) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
400 + let stream = async_status_stream(db, challenge_id);
401 + Sse::new(stream).keep_alive(KeepAlive::default())
402 +}
403 +
404 +fn async_status_stream(
405 + db: DatabaseConnection,
406 + challenge_id: Uuid,
407 +) -> impl Stream<Item = Result<Event, Infallible>> {
408 + use futures::stream;
409 +
410 + stream::unfold(
411 + StreamState {
412 + db,
413 + challenge_id,
414 + polls: 0,
415 + done: false,
416 + },
417 + |mut s| async move {
418 + if s.done || s.polls >= SSE_MAX_POLLS {
419 + return None;
420 + }
421 + if s.polls > 0 {
422 + tokio::time::sleep(SSE_POLL_INTERVAL).await;
423 + }
424 + s.polls += 1;
425 +
426 + let state_str = match FediverseAuthService::status(&s.db, s.challenge_id).await {
427 + Ok(model) => ChallengeStatus::parse(&model.status).as_str(),
428 + Err(_) => "expired",
429 + };
430 + let event = Event::default()
431 + .event("status")
432 + .data(format!(r#"{{"state":"{state_str}"}}"#));
433 +
434 + // Mark stream as terminal when status is non-pending so the
435 + // next iteration short-circuits.
436 + if state_str != "pending" {
437 + s.done = true;
438 + }
439 + Some((Ok::<_, Infallible>(event), s))
440 + },
441 + )
442 +}
443 +
444 +struct StreamState {
445 + db: DatabaseConnection,
446 + challenge_id: Uuid,
447 + polls: u32,
448 + done: bool,
449 +}
450 +
451 +// ─────────────────────────────────────────────────────────────────────
452 +// /fediverse/consent/{id} — IdP-side consent screen (iter. 4)
453 +// ─────────────────────────────────────────────────────────────────────
454 +
455 +/// Form payload for `POST /fediverse/consent/{id}`. The hidden field
456 +/// `decision` is `"approve"` or `"reject"` ; anything else is treated
457 +/// as a rejection (defensive default).
458 +#[derive(Debug, Deserialize)]
459 +pub struct ConsentForm {
460 + /// CSRF token from the hidden form field.
461 + pub csrf_token: String,
462 + /// `"approve"` or `"reject"`.
463 + pub decision: String,
464 +}
465 +
466 +/// Askama template for `GET /fediverse/consent/{id}`.
467 +#[derive(Template)]
468 +#[template(path = "fediverse/consent_screen.html")]
469 +#[allow(dead_code, missing_docs)]
470 +pub struct FediverseConsentTemplate {
471 + pub current_locale: String,
472 + pub page_title: String,
473 + pub csrf_token: String,
474 + pub heading: String,
475 + pub explainer: String,
476 + /// Domain of the requesting instance (`b.tld`).
477 + pub requester_domain: String,
478 + /// Handle the request claims to authenticate (`@alice@a.tld`).
479 + pub handle: String,
480 + pub field_label_requester: String,
481 + pub field_label_handle: String,
482 + pub approve_button: String,
483 + pub reject_button: String,
484 + /// `true` once the challenge has reached a terminal state — flips
485 + /// the action area to a status banner instead of the form.
486 + pub already_decided: bool,
487 + pub already_decided_text: String,
488 + /// Challenge UUID — embedded in the form action URL.
489 + pub challenge_id: String,
490 +}
491 +
492 +rustwarden_core::impl_template_response!(FediverseConsentTemplate);
493 +
494 +/// `GET /fediverse/consent/{challenge_id}` — render the consent screen
495 +/// for a logged-in local user.
496 +///
497 +/// The current implementation **renders** the screen but only fully
498 +/// supports the *Reject* path. *Approve* requires the inbound auth
499 +/// challenge to carry a `requested_remote_id`, which the inbox handler
500 +/// will set in a follow-up to RFC 0006 (the existing inbox `auth_inbox`
501 +/// at [`crate::fediverse::auth_routes`] documents this TODO at the
502 +/// service-layer level). When `requested_remote_id` is absent the POST
503 +/// handler refuses Approve with a clear i18n error.
504 +pub async fn consent_form(
505 + Extension(_claims): Extension<Claims>,
506 + Extension(i18n_service): Extension<Arc<I18nService>>,
507 + Extension(locale): Extension<Locale>,
508 + State(db): State<DatabaseConnection>,
509 + session: Session,
510 + Path(challenge_id): Path<Uuid>,
511 +) -> Result<axum::response::Response, AppError> {
512 + let trust = FediverseTrustService::new(db.clone());
513 + if !trust.is_enabled().await? {
514 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
515 + }
516 +
517 + let model = match FediverseAuthService::status(&db, challenge_id).await {
518 + Ok(m) => m,
519 + Err(_) => return Ok(axum::http::StatusCode::NOT_FOUND.into_response()),
520 + };
521 +
522 + let already_decided = ChallengeStatus::parse(&model.status) != ChallengeStatus::Pending;
523 +
524 + let locale_str = locale.as_str();
525 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
526 +
527 + let already_decided_text = if already_decided {
528 + i18n_service
529 + .translate("fediverse.auth.consent.already_decided", locale_str)
530 + .await
531 + } else {
532 + String::new()
533 + };
534 +
535 + let template = FediverseConsentTemplate {
536 + current_locale: locale_str.to_string(),
537 + page_title: i18n_service
538 + .translate("fediverse.auth.consent.page_title", locale_str)
539 + .await,
540 + csrf_token,
541 + heading: i18n_service
542 + .translate("fediverse.auth.consent.heading", locale_str)
543 + .await,
544 + explainer: i18n_service
545 + .translate("fediverse.auth.consent.explainer", locale_str)
546 + .await,
547 + requester_domain: model.domain,
548 + handle: model.handle,
549 + field_label_requester: i18n_service
550 + .translate("fediverse.auth.consent.label_requester", locale_str)
551 + .await,
552 + field_label_handle: i18n_service
553 + .translate("fediverse.auth.consent.label_handle", locale_str)
554 + .await,
555 + approve_button: i18n_service
556 + .translate("fediverse.auth.consent.approve", locale_str)
557 + .await,
558 + reject_button: i18n_service
559 + .translate("fediverse.auth.consent.reject", locale_str)
560 + .await,
561 + already_decided,
562 + already_decided_text,
563 + challenge_id: challenge_id.to_string(),
564 + };
565 +
566 + Ok(template.into_response())
567 +}
568 +
569 +/// `POST /fediverse/consent/{challenge_id}` — record the user's
570 +/// decision and redirect to `/home`.
571 +///
572 +/// *Reject* always succeeds. *Approve* requires the challenge row to
573 +/// have a `requested_remote_id` (set by the inbox handler when the
574 +/// `AuthRequest` arrives). Without it we surface a Validation error
575 +/// rather than mint an unbound approval, which would let an attacker
576 +/// inject any `remote_identity_id` from the form. The follow-up RFC
577 +/// 0006 Phase 2 will close the binding.
578 +pub async fn consent_submit(
579 + Extension(_claims): Extension<Claims>,
580 + State(db): State<DatabaseConnection>,
581 + session: Session,
582 + Path(challenge_id): Path<Uuid>,
583 + Form(form): Form<ConsentForm>,
584 +) -> Result<axum::response::Response, AppError> {
585 + csrf::validate_csrf_token(&session, &form.csrf_token).await?;
586 +
587 + let trust = FediverseTrustService::new(db.clone());
588 + if !trust.is_enabled().await? {
589 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
590 + }
591 +
592 + let decision = match form.decision.as_str() {
593 + "approve" => {
594 + // Re-read the challenge to source the bound remote_identity_id.
595 + let model = FediverseAuthService::status(&db, challenge_id).await?;
596 + let remote_id = model.requested_remote_id.ok_or_else(|| {
597 + AppError::Validation(
598 + "auth challenge has no remote_identity_id binding — \
599 + inbox enrichment (RFC 0006 Phase 2) not yet wired"
600 + .to_string(),
601 + )
602 + })?;
603 + ChallengeDecision::Approve {
604 + remote_identity_id: remote_id,
605 + }
606 + }
607 + // Anything other than `approve` is treated as a rejection (the
608 + // client must explicitly opt in to approval).
609 + _ => ChallengeDecision::Reject,
610 + };
611 +
612 + FediverseAuthService::record_decision(&db, challenge_id, decision).await?;
613 +
614 + Ok(Redirect::to("/home").into_response())
615 +}
616 +
617 +// ─────────────────────────────────────────────────────────────────────
618 +// /profile/connected_apps (RFC 0024 Slice C iter. 5)
619 +// ─────────────────────────────────────────────────────────────────────
620 +
621 +/// One row rendered into the connected-apps table — a remote instance
622 +/// where the current user has an active federated session (= a
623 +/// challenge in `approved` or `consumed` status).
624 +#[derive(Debug, Clone)]
625 +pub struct ConnectedAppRowCtx {
626 + /// Domain of the requesting instance (`b.tld`).
627 + pub domain: String,
628 + /// Number of distinct sessions / approvals for that domain.
629 + pub session_count: u64,
630 + /// Most recent `created_at` for this domain, formatted
631 + /// `YYYY-MM-DD HH:MM`.
632 + pub last_seen: String,
633 +}
634 +
635 +/// Askama template for `GET /profile/connected_apps`.
636 +#[derive(Template)]
637 +#[template(path = "profile/fediverse_connected_apps.html")]
638 +#[allow(dead_code, missing_docs)]
639 +pub struct ConnectedAppsTemplate {
640 + pub current_locale: String,
641 + pub page_title: String,
642 + pub heading: String,
643 + pub subtitle: String,
644 + pub rows: Vec<ConnectedAppRowCtx>,
645 + pub has_rows: bool,
646 + pub table_domain: String,
647 + pub table_count: String,
648 + pub table_last_seen: String,
649 + pub empty_title: String,
650 + pub empty_body: String,
651 + pub revoke_note: String,
652 + pub back_to_profile: String,
653 +}
654 +
655 +rustwarden_core::impl_template_response!(ConnectedAppsTemplate);
656 +
657 +/// `GET /profile/connected_apps` — section profil affichant la liste
658 +/// des instances fédérées où l'utilisateur a actuellement une session.
659 +///
660 +/// Mode dégradé silencieux (Plan UI §3 P9) : `404` quand
661 +/// `fediverse_enabled = false`. Lecture seule en iter. 5 ; le bouton
662 +/// « Révoquer » est différé à RFC 0006 Phase 2 (qui ajoutera la méthode
663 +/// `FediverseAuthService::revoke_remote_session(challenge_id)` capable
664 +/// de blacklister le JWT remote correspondant).
665 +pub async fn connected_apps(
666 + Extension(claims): Extension<Claims>,
667 + Extension(i18n_service): Extension<Arc<I18nService>>,
668 + Extension(locale): Extension<Locale>,
669 + State(db): State<DatabaseConnection>,
670 +) -> Result<axum::response::Response, AppError> {
671 + let trust = FediverseTrustService::new(db.clone());
672 + if !trust.is_enabled().await? {
673 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
674 + }
675 +
676 + // Build the local handle of the current user.
677 + let domain_self = std::env::var("FEDIVERSE_DOMAIN")
678 + .unwrap_or_else(|_| "localhost".to_string());
679 + let local_handle = format!("@{}@{}", claims.username, domain_self);
680 +
681 + // Pull all `approved` / `consumed` challenges for that handle.
682 + let challenges = fediverse_auth_challenge::Entity::find()
683 + .filter(fediverse_auth_challenge::Column::Handle.eq(local_handle))
684 + .filter(
685 + fediverse_auth_challenge::Column::Status
686 + .is_in(["approved", "consumed"]),
687 + )
688 + .order_by_desc(fediverse_auth_challenge::Column::CreatedAt)
689 + .all(&db)
690 + .await?;
691 +
692 + // Group by domain: count + most recent created_at.
693 + use std::collections::BTreeMap;
694 + let mut grouped: BTreeMap<String, (u64, chrono::DateTime<chrono::Utc>)> = BTreeMap::new();
695 + for c in challenges {
696 + let entry = grouped
697 + .entry(c.domain.clone())
698 + .or_insert((0, c.created_at));
699 + entry.0 += 1;
700 + if c.created_at > entry.1 {
701 + entry.1 = c.created_at;
702 + }
703 + }
704 + let mut rows: Vec<ConnectedAppRowCtx> = grouped
705 + .into_iter()
706 + .map(|(domain, (count, last))| ConnectedAppRowCtx {
707 + domain,
708 + session_count: count,
709 + last_seen: last.format("%Y-%m-%d %H:%M").to_string(),
710 + })
711 + .collect();
712 + // Most recently active first.
713 + rows.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
714 + let has_rows = !rows.is_empty();
715 +
716 + let locale_str = locale.as_str();
717 + let t = |k: &str| {
718 + let i18n = i18n_service.clone();
719 + let key = k.to_string();
720 + let loc = locale_str.to_string();
721 + async move { i18n.translate(&key, &loc).await }
722 + };
723 +
724 + let template = ConnectedAppsTemplate {
725 + current_locale: locale_str.to_string(),
726 + page_title: t("fediverse_connected_apps.page_title").await,
727 + heading: t("fediverse_connected_apps.heading").await,
728 + subtitle: t("fediverse_connected_apps.subtitle").await,
729 + rows,
730 + has_rows,
731 + table_domain: t("fediverse_connected_apps.table_domain").await,
732 + table_count: t("fediverse_connected_apps.table_count").await,
733 + table_last_seen: t("fediverse_connected_apps.table_last_seen").await,
734 + empty_title: t("fediverse_connected_apps.empty_title").await,
735 + empty_body: t("fediverse_connected_apps.empty_body").await,
736 + revoke_note: t("fediverse_connected_apps.revoke_note").await,
737 + back_to_profile: t("fediverse_connected_apps.back_to_profile").await,
738 + };
739 +
740 + Ok(template.into_response())
741 +}
742 +
743 +// ─────────────────────────────────────────────────────────────────────
744 +// /fediverse/invite/{id} (RFC 0024 Slice C iter. 6)
745 +// ─────────────────────────────────────────────────────────────────────
746 +
747 +/// Form payload for `POST /fediverse/invite/{id}`.
748 +#[derive(Debug, Deserialize)]
749 +pub struct InviteLandingForm {
750 + /// CSRF token from the hidden form field.
751 + pub csrf_token: String,
752 + /// `"accept"` or `"reject"`.
753 + pub decision: String,
754 +}
755 +
756 +/// Askama template for `GET /fediverse/invite/{id}`.
757 +#[derive(Template)]
758 +#[template(path = "fediverse/invite_landing.html")]
759 +#[allow(dead_code, missing_docs)]
760 +pub struct InviteLandingTemplate {
761 + pub current_locale: String,
762 + pub page_title: String,
763 + pub csrf_token: String,
764 + pub heading: String,
765 + pub explainer: String,
766 + pub team_label: String,
767 + pub team_name: String,
768 + pub role_label: String,
769 + pub role_value: String,
770 + pub status_label: String,
771 + pub status_value: String,
772 + pub accept_button: String,
773 + pub reject_button: String,
774 + /// `true` when the invite is in a terminal state (Accepted / Rejected
775 + /// / Revoked / Expired) — flips the UI to a status banner.
776 + pub already_decided: bool,
777 + pub already_decided_text: String,
778 + /// Challenge UUID for form action.
779 + pub invite_id: String,
780 + /// `true` when the current user can accept (= remote identity bound).
781 + pub can_accept: bool,
782 + pub cannot_accept_note: String,
783 +}
784 +
785 +rustwarden_core::impl_template_response!(InviteLandingTemplate);
786 +
787 +/// `GET /fediverse/invite/{invite_id}` — landing page for a federated
788 +/// team invitation.
789 +///
790 +/// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` or
791 +/// the invite does not exist. The current user (`Claims`) must be a
792 +/// **Remote** identity to be able to accept — local users only see
793 +/// the page in read-only mode (the form refuses to call `accept`
794 +/// because `record_decision` requires `remote_identity_id`).
795 +pub async fn invite_landing_form(
796 + Extension(claims): Extension<Claims>,
797 + Extension(i18n_service): Extension<Arc<I18nService>>,
798 + Extension(locale): Extension<Locale>,
799 + State(db): State<DatabaseConnection>,
800 + session: Session,
801 + axum::extract::Path(invite_id): axum::extract::Path<Uuid>,
802 +) -> Result<axum::response::Response, AppError> {
803 + let trust = FediverseTrustService::new(db.clone());
804 + if !trust.is_enabled().await? {
805 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
806 + }
807 +
808 + let Some(invite) = fediverse_pending_invite::Entity::find_by_id(invite_id)
809 + .one(&db)
810 + .await?
811 + else {
812 + return Ok((axum::http::StatusCode::NOT_FOUND, "Invite not found").into_response());
813 + };
814 +
815 + // Load team name for display.
816 + let team_name = rustwarden_core::models::team::Entity::find_by_id(invite.team_id)
817 + .one(&db)
818 + .await?
819 + .map(|t| t.name)
820 + .unwrap_or_else(|| invite.team_id.to_string());
821 +
822 + // Status terminal?
823 + use rustwarden_core::services::fediverse_invite_service::InviteStatus;
824 + let status_parsed = InviteStatus::parse(&invite.status);
825 + let already_decided = status_parsed != InviteStatus::Pending;
826 +
827 + // Can accept = current user has a remote_identity_id (RFC 0007).
828 + let can_accept = claims.is_remote() && claims.remote_identity_id().is_some();
829 +
830 + let locale_str = locale.as_str();
831 + let csrf_token = csrf::get_or_create_csrf_token(&session).await?;
832 +
833 + let t = |k: &str| {
834 + let i18n = i18n_service.clone();
835 + let key = k.to_string();
836 + let loc = locale_str.to_string();
837 + async move { i18n.translate(&key, &loc).await }
838 + };
839 +
840 + let template = InviteLandingTemplate {
841 + current_locale: locale_str.to_string(),
842 + page_title: t("fediverse_invite_landing.page_title").await,
843 + csrf_token,
844 + heading: t("fediverse_invite_landing.heading").await,
845 + explainer: t("fediverse_invite_landing.explainer").await,
846 + team_label: t("fediverse_invite_landing.team_label").await,
847 + team_name,
848 + role_label: t("fediverse_invite_landing.role_label").await,
849 + role_value: invite.role.clone(),
850 + status_label: t("fediverse_invite_landing.status_label").await,
851 + status_value: invite.status.clone(),
852 + accept_button: t("fediverse_invite_landing.accept_button").await,
853 + reject_button: t("fediverse_invite_landing.reject_button").await,
854 + already_decided,
855 + already_decided_text: if already_decided {
856 + t("fediverse_invite_landing.already_decided").await
857 + } else {
858 + String::new()
859 + },
860 + invite_id: invite_id.to_string(),
861 + can_accept,
862 + cannot_accept_note: if can_accept {
863 + String::new()
864 + } else {
865 + t("fediverse_invite_landing.cannot_accept_note").await
866 + },
867 + };
868 +
869 + Ok(template.into_response())
870 +}
871 +
872 +/// `POST /fediverse/invite/{invite_id}` — record the invitee's decision.
873 +///
874 +/// *Reject* always succeeds. *Accept* requires the current user to be a
875 +/// **Remote** identity carrying a `remote_identity_id` claim — the
876 +/// service inserts the `team_members` row with that FK.
877 +pub async fn invite_landing_submit(
878 + Extension(claims): Extension<Claims>,
879 + State(db): State<DatabaseConnection>,
880 + session: Session,
881 + axum::extract::Path(invite_id): axum::extract::Path<Uuid>,
882 + Form(form): Form<InviteLandingForm>,
883 +) -> Result<axum::response::Response, AppError> {
884 + csrf::validate_csrf_token(&session, &form.csrf_token).await?;
885 +
886 + let trust = FediverseTrustService::new(db.clone());
887 + if !trust.is_enabled().await? {
888 + return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
889 + }
890 +
891 + match form.decision.as_str() {
892 + "accept" => {
893 + let remote_id = claims.remote_identity_id().ok_or_else(|| {
894 + AppError::Validation(
895 + "Only remote identities can accept federated invites; \
896 + local users must reject or ignore."
897 + .into(),
898 + )
899 + })?;
900 + FediverseInviteService::accept(&db, invite_id, remote_id).await?;
901 + }
902 + // Anything other than "accept" is a reject (defensive default).
903 + _ => {
904 + FediverseInviteService::reject(&db, invite_id).await?;
905 + }
906 + }
907 +
908 + Ok(Redirect::to("/home").into_response())
909 +}
910 +
911 +#[cfg(test)]
912 +mod tests {
913 + use super::*;
914 +
915 + #[test]
916 + fn error_reason_parses_known_values() {
917 + assert_eq!(ErrorReason::parse("timeout"), ErrorReason::Timeout);
918 + assert_eq!(ErrorReason::parse("rejected"), ErrorReason::Rejected);
919 + assert_eq!(ErrorReason::parse("untrusted"), ErrorReason::Untrusted);
920 + assert_eq!(
921 + ErrorReason::parse("invalid_signature"),
922 + ErrorReason::InvalidSignature
923 + );
924 + assert_eq!(
925 + ErrorReason::parse("invalid_sig"),
926 + ErrorReason::InvalidSignature
927 + );
928 + }
929 +
930 + #[test]
931 + fn error_reason_falls_back_to_unknown() {
932 + assert_eq!(ErrorReason::parse(""), ErrorReason::Unknown);
933 + assert_eq!(ErrorReason::parse("garbage"), ErrorReason::Unknown);
934 + assert_eq!(ErrorReason::parse("500"), ErrorReason::Unknown);
935 + }
936 +
937 + #[test]
938 + fn error_reason_trims_and_lowercases() {
939 + assert_eq!(ErrorReason::parse(" TIMEOUT "), ErrorReason::Timeout);
940 + assert_eq!(
941 + ErrorReason::parse("Invalid_Signature"),
942 + ErrorReason::InvalidSignature
943 + );
944 + }
945 +
946 + #[test]
947 + fn extract_handle_domain_accepts_canonical() {
948 + assert_eq!(
949 + extract_handle_domain("@alice@example.org"),
950 + Some("example.org".to_string())
951 + );
952 + assert_eq!(
953 + extract_handle_domain("alice@example.org"),
954 + Some("example.org".to_string())
955 + );
956 + assert_eq!(
957 + extract_handle_domain(" @bob@MASTODON.SOCIAL "),
958 + Some("mastodon.social".to_string())
959 + );
960 + }
961 +
962 + #[test]
963 + fn extract_handle_domain_rejects_malformed() {
964 + assert_eq!(extract_handle_domain(""), None);
965 + assert_eq!(extract_handle_domain("alice"), None);
966 + assert_eq!(extract_handle_domain("alice@"), None);
967 + assert_eq!(extract_handle_domain("@@example.org"), None);
968 + }
969 +
970 + #[test]
971 + fn error_reason_round_trips_through_str() {
972 + for r in [
973 + ErrorReason::Timeout,
974 + ErrorReason::Rejected,
975 + ErrorReason::Untrusted,
976 + ErrorReason::InvalidSignature,
977 + ErrorReason::Unknown,
978 + ] {
979 + assert_eq!(ErrorReason::parse(r.as_str()), r);
980 + }
981 + }
982 +}
M crates/rustwarden-ui/src/handlers/mod.rs
+4 -0
@@ -8,6 +8,10 @@ pub mod admin_handler;
8 8 /// settings page (`/admin/fediverse/settings`).
9 9 #[cfg(feature = "experimental-fediverse")]
10 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 = "experimental-fediverse")]
14 +pub mod fediverse_user_auth;
11 15 /// Dashboard widget handlers.
12 16 pub mod dashboard_handler;
13 17 /// Email delivery status handlers.
M crates/rustwarden-ui/src/routes.rs
+102 -0
@@ -59,6 +59,33 @@ 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 = "experimental-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 +
62 89 let ui_routes = Router::new()
63 90 .route("/home", get(home_handler::home))
64 91 .route("/profile", get(profile::get_profile_page))
@@ -106,6 +133,37 @@ pub fn create_router() -> Router<DatabaseConnection> {
106 133 post(totp_handler::regenerate_backup_codes),
107 134 );
108 135
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 = "experimental-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 +
109 167 let admin_routes = Router::new()
110 168 .route("/admin", get(admin_handler::admin_dashboard))
111 169 .route("/admin/users", get(admin_handler::list_users))
@@ -147,6 +205,50 @@ pub fn create_router() -> Router<DatabaseConnection> {
147 205 "/admin/fediverse/settings",
148 206 post(admin_fediverse::update_settings),
149 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 + )
150 252 };
151 253
152 254 let admin_routes = admin_routes
A crates/rustwarden-ui/templates/admin/fediverse/nodeinfo.html
+118 -0
@@ -0,0 +1,118 @@
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 %}
A crates/rustwarden-ui/templates/admin/fediverse/queue.html
+100 -0
@@ -0,0 +1,100 @@
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 %}
A crates/rustwarden-ui/templates/admin/fediverse/sessions_remote.html
+162 -0
@@ -0,0 +1,162 @@
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 %}
A crates/rustwarden-ui/templates/admin/fediverse/team_invites.html
+167 -0
@@ -0,0 +1,167 @@
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 %}
A crates/rustwarden-ui/templates/admin/fediverse/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/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 %}
A crates/rustwarden-ui/templates/fediverse/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-ui/templates/fediverse/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-ui/templates/fediverse/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-ui/templates/fediverse/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-ui/templates/fediverse/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>
A crates/rustwarden-ui/templates/profile/fediverse_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-ui/tests/admin_handler_pagination.rs
+87 -0
@@ -0,0 +1,87 @@
1 +//! RFC 0022 — anti-régression pagination admin.
2 +//!
3 +//! Le template `templates/admin/users_list.html` utilisait
4 +//! `{% elif p >= page - 2 %}` et `{% elif p == page - 3 %}`, qui
5 +//! paniquent en `attempt to subtract with overflow` sur `u64` dès que
6 +//! `page < 3`. Toute première visite `/admin/users?page=1` (la valeur
7 +//! par défaut) crashait le worker tokio sans log d'erreur.
8 +//!
9 +//! Le fix utilise les formes additives équivalentes
10 +//! `p + 2 >= page && p <= page + 2` et `p + 3 == page || p == page + 3`
11 +//! qui n'underflow jamais sur `u64`. Ce fichier vérifie ces formes
12 +//! pour tout `(page, p) ∈ [0..10]²` et empêche un retour silencieux
13 +//! aux soustractions naïves.
14 +
15 +/// Mirroir de la condition « afficher la page p si elle est dans la
16 +/// fenêtre [page-2 ; page+2] » utilisée dans `users_list.html` ligne 211.
17 +#[inline]
18 +fn in_window_central(p: u64, page: u64) -> bool {
19 + p + 2 >= page && p <= page + 2
20 +}
21 +
22 +/// Mirroir de la condition « afficher l'ellipse … si p est exactement
23 +/// page-3 ou page+3 » utilisée dans `users_list.html` ligne 214.
24 +#[inline]
25 +fn is_ellipsis_marker(p: u64, page: u64) -> bool {
26 + p + 3 == page || p == page + 3
27 +}
28 +
29 +#[test]
30 +fn central_window_does_not_underflow_for_low_pages() {
31 + for page in 0u64..=4 {
32 + for p in 0u64..=10 {
33 + // Ne doit pas paniquer.
34 + let _ = in_window_central(p, page);
35 + let _ = is_ellipsis_marker(p, page);
36 + }
37 + }
38 +}
39 +
40 +#[test]
41 +fn central_window_matches_naive_subtraction_when_safe() {
42 + for page in 3u64..=10 {
43 + for p in 0u64..=15 {
44 + let additive = in_window_central(p, page);
45 + // Comparaison à la version naïve (sûre uniquement quand page >= 2).
46 + let naive = p >= page - 2 && p <= page + 2;
47 + assert_eq!(
48 + additive, naive,
49 + "additive form must equal naive form for page={page}, p={p}",
50 + );
51 + }
52 + }
53 +}
54 +
55 +#[test]
56 +fn ellipsis_marker_matches_naive_when_safe() {
57 + for page in 3u64..=10 {
58 + for p in 0u64..=15 {
59 + let additive = is_ellipsis_marker(p, page);
60 + // page >= 3 garantit pas d'underflow.
61 + let naive = p == page - 3 || p == page + 3;
62 + assert_eq!(
63 + additive, naive,
64 + "additive ellipsis form must equal naive for page={page}, p={p}",
65 + );
66 + }
67 + }
68 +}
69 +
70 +#[test]
71 +fn central_window_rejects_far_outside_pages() {
72 + // page=1 : fenêtre centrale = [0, 0..3]. p=4 doit être hors fenêtre.
73 + assert!(!in_window_central(4, 1));
74 + // page=10 : fenêtre = [8, 12]. p=7 hors fenêtre.
75 + assert!(!in_window_central(7, 10));
76 +}
77 +
78 +#[test]
79 +fn central_window_includes_self() {
80 + // Pour toute page, page elle-même est toujours dans la fenêtre.
81 + for page in 0u64..=20 {
82 + assert!(
83 + in_window_central(page, page),
84 + "page {page} must always include itself",
85 + );
86 + }
87 +}
A docs/fediverse-e2e-manual.md
+171 -0
@@ -0,0 +1,171 @@
1 +# Procédure E2E manuelle — Fédération ActivityPub (Slice B)
2 +
3 +> Ce document décrit le test bout-en-bout du parcours d'authentification
4 +> AP-native (RFC 0023) entre deux instances Rustwarden locales. Il
5 +> remplace le Docker Compose initialement prévu : faire tourner deux
6 +> `cargo run` sur des ports différents avec des bases distinctes est
7 +> plus rapide à itérer en dev.
8 +>
9 +> **À lire avant** : [Plan UI §7 Slice B](plans/2026-05-10-fediverse-ui-html.md#slice-b--user-facing-auth-rfc-0022)
10 +> et [RFC 0023](rfcs/0023-fediverse-ui-user-auth.md).
11 +
12 +## 1. Pré-requis
13 +
14 +- 2 bases PostgreSQL distinctes (ports `5432` et `5433`, par exemple via
15 + un `docker-compose -f database/docker-compose.yml up` étendu).
16 +- 2 dossiers `.env` distincts (un par instance) avec :
17 + - `JWT_SECRET` **différent** par instance (chaque IdP signe ses
18 + propres tokens).
19 + - `ENCRYPTION_KEY` (RFC 0005) différent.
20 + - `FEDIVERSE_DOMAIN=a.localhost` / `b.localhost`.
21 + - `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true` (autorise `http://`).
22 +- Une entrée `/etc/hosts` :
23 + ```
24 + 127.0.0.1 a.localhost b.localhost
25 + ```
26 +
27 +## 2. Lancer les deux instances
28 +
29 +Dans deux terminaux :
30 +
31 +```bash
32 +# Instance A (l'IdP — l'utilisateur "alice@a.localhost" y a son compte)
33 +cd app
34 +DATABASE_URL=postgres://localhost:5432/rustwarden_a \
35 + RUSTWARDEN_PORT=3000 \
36 + FEDIVERSE_DOMAIN=a.localhost \
37 + cargo run -p rustwarden-app --features experimental-fediverse
38 +
39 +# Instance B (le demandeur — alice s'y connecte via son identité @alice@a.localhost)
40 +DATABASE_URL=postgres://localhost:5433/rustwarden_b \
41 + RUSTWARDEN_PORT=3001 \
42 + FEDIVERSE_DOMAIN=b.localhost \
43 + cargo run -p rustwarden-app --features experimental-fediverse
44 +```
45 +
46 +Les deux instances écoutent sur `http://a.localhost:3000` et
47 +`http://b.localhost:3001`.
48 +
49 +## 3. Activer la fédération sur les deux instances
50 +
51 +Pour chaque instance, en tant qu'admin :
52 +
53 +1. Hit `/admin/fediverse/settings`.
54 +2. Cocher **« Activer la fédération ActivityPub »**.
55 +3. Choisir mode **« Liste blanche »** (zero-trust).
56 +4. Choisir scope **« Rustwarden uniquement »**.
57 +5. Soumettre.
58 +
59 +Puis **sur B** (le demandeur), hit `/admin/fediverse/trust_list` et
60 +ajouter le domaine `a.localhost` à la liste blanche. Idem **sur A** pour
61 +ajouter `b.localhost` (les deux instances s'autorisent mutuellement).
62 +
63 +## 4. Tester le parcours nominal (Approve)
64 +
65 +> ⚠️ **Étape bloquée par la dette RFC 0006 Phase 2** — l'inbox AP
66 +> (`/fediverse/auth/inbox`) ne peuplé pas encore `requested_remote_id`
67 +> sur la row challenge avant que l'utilisateur d'A ne consente. Voir
68 +> §6 ci-dessous pour la portion testable aujourd'hui.
69 +
70 +1. Sur **B** (`http://b.localhost:3001`), aller sur `/login/fediverse`.
71 +2. Saisir `@alice@a.localhost` et soumettre.
72 +3. Le navigateur affiche `/login/fediverse/waiting/{uuid}` (spinner SSE).
73 +4. **Sur A**, ouvrir un onglet incognito loggué en `alice` et hit
74 + `/fediverse/consent/{uuid}` (récupéré depuis les logs B).
75 +5. Cliquer **Approuver**.
76 + - Comportement attendu **post Phase 2** : la session sur B est
77 + activée, redirect `/home` côté B.
78 + - Comportement actuel : le handler refuse Approve avec
79 + `AppError::Validation("auth challenge has no remote_identity_id binding ...")`.
80 + Le toast d'erreur s'affiche.
81 +
82 +## 5. Tester les chemins négatifs (testable aujourd'hui)
83 +
84 +### 5.1 Refus côté IdP
85 +
86 +1. Sur B, soumettre `@alice@a.localhost`.
87 +2. Sur A en tant qu'admin, hit `/fediverse/consent/{uuid}` et cliquer
88 + **Refuser**.
89 +3. **Attendu** : SSE émet `event: status` avec `state=rejected`, B
90 + redirige sur `/login/fediverse/error?reason=rejected`. ✅
91 +
92 +### 5.2 Domaine non listé
93 +
94 +1. Retirer `a.localhost` de la trust list sur B.
95 +2. Sur B, soumettre `@alice@a.localhost`.
96 +3. **Attendu** : redirect immédiat `/login/fediverse/error?reason=untrusted`. ✅
97 +
98 +### 5.3 Handle malformé
99 +
100 +1. Sur B, `/login/fediverse`, saisir `pasunhandle`.
101 +2. **Attendu** : redirect `/login/fediverse/error?reason=unknown`. ✅
102 +
103 +### 5.4 Timeout
104 +
105 +1. Sur B, soumettre `@alice@a.localhost`.
106 +2. Attendre 5 minutes sans répondre côté A.
107 +3. **Attendu** : SSE émet `state=expired`, B redirige sur
108 + `/login/fediverse/error?reason=timeout`. ✅
109 +
110 +### 5.5 Mode dégradé silencieux (Plan UI §3 P9)
111 +
112 +1. Sur B, désactiver `fediverse_enabled` via `/admin/fediverse/settings`.
113 +2. Hit `/login/fediverse`, `/login/fediverse/error`,
114 + `/login/fediverse/waiting/{any}`, `/login/fediverse/sse/{any}`,
115 + `/admin/fediverse/trust_list`, `/fediverse/consent/{any}`.
116 +3. **Attendu** : tous renvoient `404` ; seule
117 + `/admin/fediverse/settings` reste accessible (pour permettre la
118 + réactivation). ✅
119 +
120 +## 6. Critères d'acceptation Slice B
121 +
122 +| Critère | Statut iter. 5 |
123 +|---|---|
124 +| Login form rendu, validation handle | ✅ |
125 +| Trust check côté demandeur | ✅ |
126 +| Initiate persiste challenge `pending` | ✅ |
127 +| Page d'attente + SSE poll | ✅ |
128 +| Consent screen IdP (Reject) | ✅ |
129 +| Consent screen IdP (Approve) | ⚠️ bloqué Phase 2 |
130 +| 4 variantes d'erreur (timeout/rejected/untrusted/invalid_sig) | ✅ |
131 +| Mode dégradé silencieux (404 si feature off) | ✅ |
132 +| A11y `aria-live`, `<label for>` | ✅ |
133 +| i18n FR + EN complet | ✅ |
134 +| Lighthouse a11y ≥ 95 | non mesuré (manuel) |
135 +
136 +## 7. Dette à fermer pour E2E complet (Phase 2)
137 +
138 +[`crates/rustwarden-core/src/fediverse/auth_routes.rs:188`](../crates/rustwarden-core/src/fediverse/auth_routes.rs)
139 +documente déjà le TODO : quand l'inbox AP reçoit un `AuthRequest`,
140 +elle doit :
141 +
142 +1. Persister une row `fediverse_auth_challenges` côté IdP avec
143 + `domain = requester_domain`, `handle = local_handle`.
144 +2. **Résoudre** le `local_handle` vers la row `remote_identities`
145 + correspondante (créer si absente, conformément à RFC 0003).
146 +3. **Stocker** ce `remote_identities.id` dans `requested_remote_id` sur
147 + la challenge avant que l'utilisateur ne voie l'écran de consent.
148 +
149 +Sans ces 3 étapes, `consent_submit` refuse Approve avec une erreur
150 +Validation explicite (sécurité : un attaquant ne peut pas injecter un
151 +`remote_identity_id` arbitraire via le form).
152 +
153 +L'iter. 5 du master plan §10 prévoit cette wiring comme livrable RFC
154 +0006 Phase 2 — pas dans le scope UI Slice B (RFC 0023).
155 +
156 +## 8. Tests automatisés (statut)
157 +
158 +Les tests unitaires existants couvrent :
159 +
160 +- `ErrorReason::parse` — 4 tests (`fediverse_user_auth.rs:tests`)
161 +- `extract_handle_domain` — 2 tests (idem)
162 +- HTTP signatures round-trip via wiremock — RFC 0020
163 + (`tests/fediverse_interop.rs`)
164 +
165 +L'**E2E web** (Playwright / curl scripts) est différé jusqu'à RFC 0006
166 +Phase 2 — sinon les scénarios « happy path » ne couvrent que les
167 +chemins négatifs déjà testés en unitaires.
168 +
169 +---
170 +
171 +*Dernière mise à jour : 2026-05-10 (Slice B iter. 5 close-out).*
M docs/plans/2026-05-09-fediverse-identity-federation.md
+1 -1
@@ -784,7 +784,7 @@ gh pr list --repo LemmyNet/activitypub-federation-rust --state all --limit 30 \
784 784 | 2 — Auth AP-native | ✅ **Service livré** (3/3 RFCs livrées, routes HTTP différées) | RFC 0006 ✅, 0007 ✅, 0011 ✅ | 2026-05-09 |
785 785 | 3 — Identity abstraction & invitations | ✅ **Service livré** (RFC 0008-0009 livrées, routes HTTP & PK relax différées) | RFC 0008 ✅, 0009 ✅ | 2026-05-09 |
786 786 | 4 — Signaux & lifecycle | ✅ **Service livré** (RFC 0010 livrée, routes inbox & HTTP-Sig réelle différées) | RFC 0010 ✅ | 2026-05-10 |
787 -| 5 — Production | 🔄 **Service, monitoring, interop, audit gate + UI Slice A iter.1 livrés** (UI Slice A trust_list + Slices B/C restants) | RFC 0015, 0016, 0017, 0018, 0019, 0020, 0021 (iter.1) + [`docs/fediverse.md`](../fediverse.md) + [`.cargo/audit.toml`](../../.cargo/audit.toml) + [`Plan UI`](2026-05-10-fediverse-ui-html.md) | 2026-05-10 |
787 +| 5 — Production | ✅ **Slices A + B + C clôturés + RFC 0006 Phase 2 livrée** — reste optionnel : tests interop Mastodon réel, charge testing manuel, Lighthouse a11y | RFC 0015–0024 + Phase 2 inbox enrichment + [`docs/fediverse.md`](../fediverse.md) + [`docs/fediverse-e2e-manual.md`](../fediverse-e2e-manual.md) + [`.cargo/audit.toml`](../../.cargo/audit.toml) + [`Plan UI`](2026-05-10-fediverse-ui-html.md) | 2026-05-10 |
788 788
789 789 ### RFCs détaillées
790 790
A docs/rfcs/0022-smoke-tests-regression.md
+99 -0
@@ -0,0 +1,99 @@
1 +# RFC 0022 — Smoke tests anti-régression (middleware + pagination)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-10
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 26/35)
6 +- **Apps consommatrices concernées** : toutes — ces tests vivent dans `rustwarden-core` et `rustwarden-ui` et tournent dans le suite par défaut.
7 +- **Plan associé** : aucun (livrable autonome qui répond à une régression observée le 2026-05-10).
8 +
9 +## Summary
10 +
11 +Ajouter deux fichiers de tests qui auraient permis de **détecter en CI** les régressions découvertes manuellement aujourd'hui :
12 +
13 +1. `crates/rustwarden-core/tests/middleware_authconfig_fallback.rs` — vérifie que `AuthService` et `AdminService` ne renvoient pas `500` quand `Extension(Arc<AuthConfig>)` n'est pas appliqué *avant* dans le `ServiceBuilder` (c'est-à-dire qu'ils retombent bien sur `AuthConfig::from_env()`).
14 +2. `crates/rustwarden-ui/tests/admin_handler_pagination.rs` — vérifie que `admin_handler::list_users` ne panique pas sur `?page=0`, `?page=1`, `?page=2` (le template `users_list.html` faisait `page - 2` qui underflow sur `u64`).
15 +
16 +Les deux tests sont **isolés** (Mock DB / direct service call), pas de PostgreSQL ni de runtime complet requis. Total : ~120 LoC.
17 +
18 +## Motivation
19 +
20 +Les régressions vues aujourd'hui :
21 +
22 +- **Régression 1** (commit `d4d6828`, 2026-05-08) — `AuthService` est passé de `AuthConfig::from_env()` à `parts.extensions.get::<Arc<AuthConfig>>()` strict. Tout consommateur dont le `ServiceBuilder` met le layer `Extension(auth_config)` après le layer `AuthService` voit toutes les routes protégées renvoyer `500` silencieusement (pas de log `ERROR`). Idem `AdminService` et `admin_helper`.
23 +- **Régression 2** (pré-existante) — `templates/admin/users_list.html` lignes 211 / 214 contiennent `p >= page - 2` et `p == page - 3`. Sur `u64`, ces soustractions paniquent quand `page < 3`. Toute première visite `/admin/users` (par défaut `page=1`) crash le worker tokio.
24 +
25 +**Ce que les tests existants ne couvraient pas** :
26 +
27 +- Aucun test n'instancie un `ServiceBuilder` proche de la prod et fire une requête.
28 +- Aucun test n'exerce `list_users` avec `page=1` (les 6 tests `rustwarden-ui` couvrent uniquement les DTOs).
29 +- `clippy` / `cargo doc` / les unit tests de RFC 0011/0021 ne touchent pas le câblage middleware ni les chemins de rendu Askama.
30 +
31 +## Non-goals
32 +
33 +- **Pas de smoke test full-stack** qui boote `RustwardenApp::build()` et lance un serveur réel. Trop coûteux en CI ; suivi dans une RFC future.
34 +- **Pas de property-based tests** sur la pagination — un échantillon de pages représentatives suffit pour cette classe de bug.
35 +- **Pas d'intégration `wiremock`** ici — ces tests ciblent le câblage interne, pas l'interop externe (déjà couvert RFC 0020).
36 +
37 +## API proposée
38 +
39 +Aucune API publique modifiée.
40 +
41 +### Test 1 — middleware AuthConfig fallback
42 +
43 +```
44 +crates/rustwarden-core/tests/middleware_authconfig_fallback.rs
45 +```
46 +
47 +Stratégie :
48 +
49 +1. Setter `JWT_SECRET` et autres variables `AuthConfig` minimales.
50 +2. Construire un `ServiceBuilder` qui n'a *pas* de `.layer(Extension(Arc<AuthConfig>))`.
51 +3. Le wrapper avec `AuthService::new(inner, db)`.
52 +4. Fire une requête sur `/anything` avec un JWT cookie minimal.
53 +5. Asserter que la réponse n'est **pas** `500`.
54 +
55 +### Test 2 — pagination handler
56 +
57 +```
58 +crates/rustwarden-ui/tests/admin_handler_pagination.rs
59 +```
60 +
61 +Stratégie : pour chaque `page ∈ {0, 1, 2, 3, 100}`, instancier `Query<UserListQuery>`, appeler `list_users` avec `MockDatabase` (vide ou 1 user), asserter `Ok(200)` et l'absence de panic.
62 +
63 +## Compatibilité
64 +
65 +Aucun impact runtime. Les tests sont sous `[dev-dependencies]` existants. Pas de nouvelle dépendance.
66 +
67 +## Alternatives
68 +
69 +1. **Ne rien faire** — *rejeté* : régressions silencieuses quasi-certaines (déjà 2 en 48 h).
70 +2. **Smoke test full-stack avec PostgreSQL** — *différé* : exige docker-compose en CI, latence × 30s.
71 +3. **Property-based via `proptest`** — *différé* : utile pour la pagination mais on couvre les cas avec 5 valeurs représentatives.
72 +
73 +## Testing plan
74 +
75 +Les tests **sont eux-mêmes** le testing plan. Validation :
76 +
77 +- `cargo test --workspace -p rustwarden-core --test middleware_authconfig_fallback` → 1 pass
78 +- `cargo test --workspace -p rustwarden-ui --test admin_handler_pagination` → 5 pass (un par valeur de `page`)
79 +- Reverter manuellement les fixes des bugs d'aujourd'hui → les tests doivent **échouer**, prouvant qu'ils ferment la boucle.
80 +
81 +## Migration guide
82 +
83 +Aucune. Les tests sont additifs.
84 +
85 +## Open questions
86 +
87 +- **Q1** : faut-il étendre à un *vrai* smoke test avec `axum::serve` ? Réponse : oui, suivi RFC 0023 quand on aura besoin (par ex. valider un lifecycle complet de session).
88 +
89 +## Score (26/35)
90 +
91 +| Critère | Note | Détail |
92 +|---|---|---|
93 +| Généralité | 4/5 | Sert toute app `rustwarden-*`. |
94 +| Stabilité API | 5/5 | Tests purs, zéro impact API. |
95 +| Performance | 5/5 | `MockDatabase` + service unitaire = ms par test. |
96 +| Sécurité | 3/5 | Indirectement : empêche un 500 silencieux exploitable comme oracle. |
97 +| Complexité | 3/5 | ~120 LoC totaux, bien dans la norme. |
98 +| Maintenance | 3/5 | Les tests doivent évoluer avec la signature des middlewares. |
99 +| Documentation | 3/5 | Cette RFC + commentaires inline. |
A docs/rfcs/0023-fediverse-ui-user-auth.md
+118 -0
@@ -0,0 +1,118 @@
1 +# RFC 0023 — Fediverse UI : parcours utilisateur d'authentification fédérée (Slice B)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-10
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 26/35)
6 +- **Apps consommatrices concernées** : toute app `rustwarden-ui` qui veut activer la connexion AP-native (utilisateur d'instance fédérée se connecte sur l'instance locale).
7 +- **Plan associé** : [Plan UI HTML §7 Slice B](../plans/2026-05-10-fediverse-ui-html.md#slice-b--user-facing-auth-rfc-0022).
8 +- **Dépendances** : RFC 0006 (`FediverseAuthService` service-layer), RFC 0013 (5 routes JSON `/api/v1/auth/login/fediverse/*`), RFC 0021 (toggle `fediverse_enabled` + trust list).
9 +
10 +## Summary
11 +
12 +Livrer les **4 écrans HTML** du parcours d'authentification AP-native, qui déclenchent et accompagnent le round-trip avec l'IdP fédéré via les routes JSON livrées par RFC 0013 :
13 +
14 +1. **`fediverse/error_unauthorized.html`** — page d'erreur user-friendly avec 4 variantes (`timeout` / `rejected` / `untrusted` / `invalid_signature`). C'est la cible du redirect quand l'auth échoue à toute étape.
15 +2. **Extension `login.html`** — ajoute un champ « Se connecter avec une identité ActivityPub » (handle `@user@domain`) quand `fediverse_enabled = true`. Submit POST UI → handler `initiate` → redirect vers `/login/fediverse/waiting/{challenge_id}`.
16 +3. **`fediverse/login_waiting.html`** — page d'attente sur l'instance demandeuse. Polling SSE de `/api/v1/auth/login/fediverse/status/{challenge_id}` (RFC 0013) avec spinner + skeleton ; redirige vers `/home` (consume) ou `/login/fediverse/error?reason=…` selon issue.
17 +4. **`fediverse/consent_screen.html`** — sur l'IdP, rendu quand l'utilisateur (déjà loggué localement) reçoit une demande d'auth fédérée d'une autre instance. Affiche domaine demandeur, scope, boutons Approve / Reject.
18 +
19 +## Motivation
20 +
21 +Sans ces pages, le parcours d'auth fédéré est uniquement pilotable depuis une SPA ou un client API. Plan UI Plan §7 identifie ce parcours comme **critique pour l'adoption** : c'est le premier moment où un utilisateur externe découvre Rustwarden via une instance fédérée.
22 +
23 +## Non-goals
24 +
25 +- **Pas de WebAuthn / passkey** intégré au flow — restera OAuth/OIDC classique pour les comptes locaux.
26 +- **Pas de bulk-approve** sur la page consent — un challenge à la fois.
27 +- **Pas de QR-code mobile** pour scanner un handle — différé.
28 +- **Pas d'UI de gestion historique** des challenges sur l'IdP (audit log) — couvert par Slice C `admin/sessions_remote.html`.
29 +
30 +## API proposée
31 +
32 +### Routes UI HTML (rustwarden-ui)
33 +
34 +| Méthode | Path | Handler | Auth |
35 +|---|---|---|---|
36 +| GET | `/login/fediverse` | `login_fediverse::form` | public |
37 +| POST | `/login/fediverse` | `login_fediverse::initiate` | public + CSRF |
38 +| GET | `/login/fediverse/waiting/{challenge_id}` | `login_fediverse::waiting` | public |
39 +| GET | `/login/fediverse/sse/{challenge_id}` | `login_fediverse::sse_status` | public (challenge bound) |
40 +| GET | `/login/fediverse/error` | `login_fediverse::error` | public |
41 +| GET | `/fediverse/consent/{challenge_id}` | `consent::form` | local user logged-in |
42 +| POST | `/fediverse/consent/{challenge_id}` | `consent::submit` | local user + CSRF |
43 +
44 +Les handlers UI **délèguent** systématiquement à `FediverseAuthService` (RFC 0006) pour la logique métier ; ils ne touchent pas la state machine du challenge.
45 +
46 +Le mode dégradé silencieux (Plan UI §3 P9) s'applique : toutes ces routes retournent `404` quand `fediverse_enabled = false`, sauf `/login` qui reste accessible (le champ AP est juste masqué via `{% if fediverse_enabled %}`).
47 +
48 +### Templates Askama (nouveaux)
49 +
50 +```
51 +templates/admin/fediverse/ ← Slice A
52 +templates/fediverse/
53 +├── error_unauthorized.html # iter. 1
54 +├── login_waiting.html # iter. 3
55 +├── consent_screen.html # iter. 4
56 +templates/login.html # extension iter. 2
57 +```
58 +
59 +### i18n (nouvelles clés `fediverse.auth.*`)
60 +
61 +- `error.title` + `error.<reason>.title` + `error.<reason>.body` × 4 reasons
62 +- `login.field_label` + `login.placeholder` + `login.submit`
63 +- `waiting.title` + `waiting.subtitle` + `waiting.cancel`
64 +- `consent.title` + `consent.body` (templated `{domain}`) + `consent.scope_<key>` + `consent.approve` + `consent.reject`
65 +
66 +## Compatibilité
67 +
68 +**Aucun impact** sur les apps qui n'activent pas `experimental-fediverse`. Les routes UI sont gated par feature, exactement comme Slice A.
69 +
70 +L'extension de `login.html` est conditionnelle : sans `fediverse_enabled = true`, le markup AP reste invisible — pas de régression UX pour les apps qui n'utilisent pas la fédération.
71 +
72 +## Alternatives
73 +
74 +1. **Ne rien faire / SPA-only** — *rejeté* : l'app de référence Rustwarden ship une UI HTML ; un parcours critique non couvert HTML brise l'expérience cohérente.
75 +2. **Polling AJAX au lieu de SSE** — *rejeté* : SSE est exactement le shape attendu (one-way push depuis le serveur), conserve la connexion ouverte sans re-poll, et `axum::response::sse` est intégré.
76 +3. **WebSocket** — *rejeté* : sur-ingénierie, pas de besoin bidirectionnel.
77 +4. **Tout en une seule iter** — *rejeté* : on a vu sur Slice A que 4 templates + handlers + tests = 4-5 turns. On déroule en 4 itérations livrables individuellement.
78 +
79 +## Stratégie d'itérations
80 +
81 +| Iter | Livrable | Priorité |
82 +|---|---|---|
83 +| 1 | `error_unauthorized.html` + handler + 4 variantes + i18n | indépendant, déblocant pour les autres iter |
84 +| 2 | Extension `login.html` + handler `initiate` | parcours entrant fonctionnel jusqu'à waiting |
85 +| 3 | `login_waiting.html` + SSE polling | ferme la boucle côté demandeur |
86 +| 4 | `consent_screen.html` + handler approve/reject | côté IdP, ferme E2E |
87 +| 5 | Tests E2E deux instances local + a11y Lighthouse | acceptation |
88 +
89 +Chaque iter passe les 5 quality gates (build / test / clippy / doc / migration N/A) avant la suivante.
90 +
91 +## Testing plan
92 +
93 +- **iter. 1** : test handler `error` rendu sur `?reason=timeout|rejected|untrusted|invalid_signature` → 200 + i18n attendue.
94 +- **iter. 2** : test handler `initiate` valide le handle (regex `@user@domain`), appelle `FediverseAuthService::initiate`, retourne redirect 303.
95 +- **iter. 3** : test SSE handler émet bien `event: status` quand le challenge passe à `Accepted` / `Rejected` ; aucun test client (le navigateur s'en charge).
96 +- **iter. 4** : test handler consent — POST approve appelle `record_decision(Accepted)`, POST reject → `record_decision(Rejected)`, mauvais CSRF → 403.
97 +- **iter. 5** : harness deux binaires Rustwarden (`docker-compose.fediverse-test.yml`) avec `debug_local_federation=true` → playwright/curl scripté login E2E.
98 +
99 +## Migration guide
100 +
101 +Aucune. Pure addition de routes + templates. Apps qui activent `experimental-fediverse` doivent simplement reconstruire leur binaire et fournir le pack i18n.
102 +
103 +## Open questions
104 +
105 +- **Q1** : le `cancel` sur `login_waiting.html` doit-il appeler une route DELETE pour cleaner le challenge en DB, ou laisser timeout naturel (TTL 5 min, RFC 0006) ? *Réponse provisoire* : TTL suffit ; l'utilisateur ferme l'onglet, le worker nettoie. DELETE explicite peut s'ajouter en RFC future.
106 +- **Q2** : dois-je restreindre `?reason=` à un enum strict ou tolérer du texte libre ? *Réponse* : enum strict (whitelist 4 valeurs), tout le reste tombe sur la variante `unknown` qui affiche un message générique.
107 +
108 +## Score (26/35)
109 +
110 +| Critère | Note | Détail |
111 +|---|---|---|
112 +| Généralité | 4/5 | Réutilisable par toute app `rustwarden-ui`. |
113 +| Stabilité API | 5/5 | Pures additions de routes UI, aucun breaking. |
114 +| Performance | 4/5 | SSE + polling DB → mesurable, mais bornée par TTL 5 min. |
115 +| Sécurité | 4/5 | Le parcours touche l'auth, mais SignatureVerifier (RFC 0015) reste autoritatif sur l'IdP. CSRF sur les forms. |
116 +| Complexité | 2/5 | 4 templates + 5 handlers + SSE = ~800 LoC sur 4 iters. |
117 +| Maintenance | 4/5 | Pattern existant Slice A à recopier. |
118 +| Documentation | 3/5 | Cette RFC + rustdoc + 4 entrées CHANGELOG. |
A docs/rfcs/0024-fediverse-ui-ops-slice-c.md
+123 -0
@@ -0,0 +1,123 @@
1 +# RFC 0024 — Fediverse UI : invitations & ops admin (Slice C)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-10
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 26/35)
6 +- **Apps consommatrices concernées** : toute app `rustwarden-ui` qui veut un dashboard ops + flux invitations cross-instance.
7 +- **Plan associé** : [Plan UI HTML §7 Slice C](../plans/2026-05-10-fediverse-ui-html.md#slice-c--invitations--ops-rfc-0023).
8 +- **Dépendances** : RFC 0011 (file outbound), RFC 0019 (tracing), RFC 0009 (FediverseInviteService), RFC 0021 (admin foundations).
9 +
10 +## Summary
11 +
12 +Livrer **6 écrans HTML restants** du parcours fédération admin/ops :
13 +
14 +| # | Écran | Iter |
15 +|---|---|---|
16 +| 1 | `/admin/fediverse/queue` — dashboard file outbound (status counts + 100 dernières lignes) | 1 ✅ |
17 +| 2 | `/admin/fediverse/sessions_remote` — sessions distantes actives + kill switch par domaine | 2 |
18 +| 3 | `/admin/fediverse/nodeinfo` — stats NodeInfo humanisées | 3 |
19 +| 4 | `/admin/teams/{id}/invites` — invitations cross-instance (form + table pending/accepted/rejected/revoked) | 4 |
20 +| 5 | `/profile/connected_apps` — section profil "Apps connectées" + bouton révoquer | 5 |
21 +| 6 | `/fediverse/invite/{id}` — page acceptation d'invitation (côté IdP du destinataire) | 6 |
22 +
23 +L'iter. 1 ferme la livraison en une passe ; les iter. 2-6 réutilisent le pattern Slice A (handler + template + i18n + route).
24 +
25 +## Motivation
26 +
27 +Slice A donne le contrôle de la fédération (toggle + trust). Slice B donne l'auth utilisateur. Slice C donne **la visibilité** (queue, NodeInfo, sessions) et les **flux invitations cross-instance** (RFC 0009). Sans ces pages, un opérateur n'a pas de signal sur la santé du système et un admin team ne peut pas inviter quelqu'un d'une autre instance.
28 +
29 +## Non-goals
30 +
31 +- **Pas de graphiques temps-réel** dans `queue.html` — un refresh manuel suffit. Les graphes Prometheus ressortent des events RFC 0019.
32 +- **Pas d'éditions inline** sur les invitations — révoquer = action atomique.
33 +- **Pas de bulk-revoke** sur sessions distantes — une à la fois ; le kill switch domaine couvre les cas de masse.
34 +- **Pas de pagination sophistiquée** — limite 100 rows par page suffit pour le MVP ; tri par created_at desc.
35 +
36 +## API proposée
37 +
38 +### Routes UI HTML (rustwarden-ui)
39 +
40 +| Méthode | Path | Handler | Auth |
41 +|---|---|---|---|
42 +| GET | `/admin/fediverse/queue` | `admin_fediverse::get_queue` | admin |
43 +| GET | `/admin/fediverse/sessions_remote` | `admin_fediverse::get_sessions_remote` | admin |
44 +| POST | `/admin/fediverse/sessions_remote/{domain}/kill` | `admin_fediverse::kill_domain` | admin + CSRF |
45 +| POST | `/admin/fediverse/sessions_remote/{id}/revoke` | `admin_fediverse::revoke_remote_session` | admin + CSRF |
46 +| GET | `/admin/fediverse/nodeinfo` | `admin_fediverse::get_nodeinfo` | admin |
47 +| GET | `/admin/teams/{team_id}/invites` | `admin_invites::get_invites` | admin team |
48 +| POST | `/admin/teams/{team_id}/invites` | `admin_invites::create_invite` | admin team + CSRF |
49 +| POST | `/admin/teams/{team_id}/invites/{id}/revoke` | `admin_invites::revoke_invite` | admin team + CSRF |
50 +| GET | `/profile/connected_apps` | `profile_fediverse::connected_apps` | logged-in user |
51 +| POST | `/profile/connected_apps/{host}/revoke` | `profile_fediverse::revoke_app` | logged-in user + CSRF |
52 +| GET | `/fediverse/invite/{id}` | `invite_landing::form` | logged-in user |
53 +| POST | `/fediverse/invite/{id}` | `invite_landing::submit` | logged-in user + CSRF |
54 +
55 +Tous les endpoints `/admin/fediverse/*` sont gated par feature + 404 si `fediverse_enabled = false`. Les endpoints `/profile/connected_apps` et `/fediverse/invite/*` aussi (P9 du Plan UI).
56 +
57 +### Templates Askama (nouveaux)
58 +
59 +```
60 +templates/admin/fediverse/
61 +├── queue.html # iter. 1 ✅
62 +├── sessions_remote.html # iter. 2
63 +└── nodeinfo.html # iter. 3
64 +templates/admin/teams/
65 +└── invites.html # iter. 4
66 +templates/profile/
67 +└── connected_apps.html # iter. 5
68 +templates/fediverse/
69 +└── invite_landing.html # iter. 6
70 +```
71 +
72 +## Compatibilité
73 +
74 +Aucun impact sur les apps qui n'activent pas `experimental-fediverse`. Pures additions, gated.
75 +
76 +`AuthService::call` accepte déjà les préfixes `/admin`, `/profile`, `/settings` ; le seul nouveau préfixe à enregistrer pour Slice C est `/fediverse/invite` (sera ajouté en iter. 6).
77 +
78 +## Alternatives
79 +
80 +1. **Tout en une RFC monolithique** — *rejeté* : on a vu sur Slice B que 4-5 écrans sur une seule RFC mène à des turns trop chargés ; le découpage en iter livrables séparément a bien fonctionné.
81 +2. **Reporter Slice C jusqu'à RFC 0006 Phase 2** — *rejeté* : Slice C est largement indépendant de Phase 2. Seul `connected_apps` voit potentiellement des sessions remote = 0 tant que Phase 2 n'est pas livrée, mais le rendu page vide est testable.
82 +3. **Page admin/sessions_remote en read-only** sans kill switch — *rejeté* : sans action destructive, l'admin ne peut pas répondre à un incident (instance fédérée compromise → tuer toutes les sessions de ce domaine).
83 +
84 +## Stratégie d'itérations
85 +
86 +| Iter | Livrable | État |
87 +|---|---|---|
88 +| 1 | `/admin/fediverse/queue` (RFC 0011 + RFC 0019 visualisés) | ✅ livré |
89 +| 2 | `/admin/fediverse/sessions_remote` + kill switch | À venir |
90 +| 3 | `/admin/fediverse/nodeinfo` | À venir |
91 +| 4 | `/admin/teams/{id}/invites` (utilise RFC 0009 `FediverseInviteService`) | À venir |
92 +| 5 | `/profile/connected_apps` | À venir |
93 +| 6 | `/fediverse/invite/{id}` (parcours destinataire) | À venir |
94 +
95 +Chaque iter passe les 5 quality gates (build / test / clippy / doc / migration N/A) avant la suivante.
96 +
97 +## Testing plan
98 +
99 +- **Iter. 1** : test handler `get_queue` rendu — empty state quand 0 row, table quand >0. Couvert par MockDatabase.
100 +- **Iter. 2** : test kill switch invalide une session, audit log généré.
101 +- **Iter. 3** : test nodeinfo response shape.
102 +- **Iter. 4-6** : tests handler invitations (création / révocation) + acceptation côté destinataire.
103 +
104 +## Migration guide
105 +
106 +Aucune. Pure addition de routes + templates.
107 +
108 +## Open questions
109 +
110 +- **Q1** : faut-il pousser la file outbound en SSE temps-réel ou laisser refresh manuel ? *Réponse provisoire* : refresh manuel ; SSE seulement si la demande émerge en prod.
111 +- **Q2** : sessions_remote utilise quelle source de vérité — JWT blacklist ou RemoteIdentities ? *Réponse* : RemoteIdentities (les rows actives = sessions actives).
112 +
113 +## Score (26/35)
114 +
115 +| Critère | Note | Détail |
116 +|---|---|---|
117 +| Généralité | 4/5 | 6 écrans réutilisables. |
118 +| Stabilité API | 5/5 | Pures additions. |
119 +| Performance | 3/5 | LIMIT 100 + count() par status — borné mais à reviser si > 10k rows. |
120 +| Sécurité | 4/5 | CSRF + admin_required + kill switch destructif (modale obligatoire). |
121 +| Complexité | 2/5 | 6 écrans = ~1200 LoC sur 6 iters. |
122 +| Maintenance | 4/5 | Pattern existant Slice A/B copié. |
123 +| Documentation | 4/5 | Cette RFC + 6 entrées CHANGELOG par iter. |
M docs/rfcs/README.md
+3 -0
@@ -80,6 +80,9 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
80 80 | [0019](0019-fediverse-outbound-metrics.md) | Fediverse — Observabilité du worker outbound (tracing counters) | **Accepted** (27/35) | EBO | 2026-05-10 |
81 81 | [0020](0020-fediverse-interop-tests.md) | Fediverse — Tests d'interopérabilité wiremock (signed delivery round-trip) | **Accepted** (26/35) | EBO | 2026-05-10 |
82 82 | [0021](0021-fediverse-ui-admin-foundations.md) | Fediverse UI — Admin foundations (settings + trust list) | **Accepted** (26/35) | EBO | 2026-05-10 |
83 +| [0022](0022-smoke-tests-regression.md) | Smoke tests anti-régression (middleware AuthConfig + pagination admin) | **Accepted** (26/35) | EBO | 2026-05-10 |
84 +| [0023](0023-fediverse-ui-user-auth.md) | Fediverse UI — Parcours utilisateur d'authentification fédérée (Slice B) | **Accepted** (26/35) | EBO | 2026-05-10 |
85 +| [0024](0024-fediverse-ui-ops-slice-c.md) | Fediverse UI — Invitations & ops admin (Slice C, 6 écrans) | **Accepted** (26/35) | EBO | 2026-05-10 |
83 86
84 87 À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant.
85 88