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 |
GitRust