Skip to main content

rustio_admin/admin/
routes.rs

1//! Admin route registration with permission checks.
2//!
3//! Every admin URL is gated by a specific permission:
4//!   GET  /admin/:model            → posts.view_post
5//!   GET  /admin/:model/new        → posts.add_post
6//!   POST /admin/:model/new        → posts.add_post
7//!   GET  /admin/:model/:id/edit   → posts.change_post
8//!   POST /admin/:model/:id/edit   → posts.change_post
9//!   GET  /admin/:model/:id/delete → posts.delete_post
10//!   POST /admin/:model/:id/delete → posts.delete_post
11//!
12//! Administrator + Developer bypass every check (see
13//! `Role::bypasses_group_checks`). Staff and Supervisor need the
14//! specific permission granted either directly or via a group.
15//!
16//! Slimmed for Tier 1: the legacy file's developer stub routes
17//! (`__schema__`, `__logs__`, `__sql_console__`) and the FK remote-
18//! search endpoint have been dropped. Everything else — `/static/admin.css`
19//! and `/static/admin.js` (P8), login/logout, dashboard,
20//! /admin/users/*, /admin/groups/*, /admin/history,
21//! /admin/password_change, /admin/:model/* CRUD,
22//! /admin/:model/:id/history — is wired below.
23
24use std::sync::Arc;
25
26use crate::auth::{self, Identity, Role};
27use crate::error::{Error, Result};
28use crate::http::{Request, Response};
29use crate::orm::Db;
30use crate::router::Router;
31use crate::templates::Templates;
32
33/// Embedded stylesheet baked into the binary.
34///
35/// The admin stylesheet is authored as a multi-file architecture
36/// under `assets/static/admin/` (tokens → themes → base → layout →
37/// components → pages → responsive → print), modelled on GitHub
38/// Primer / IBM Carbon. The browser still receives one concatenated
39/// bundle so we keep our "self-hosted, single round-trip, no FOUT"
40/// doctrine.
41///
42/// Order below MUST mirror the `@import` manifest in
43/// `assets/static/admin/admin.css` exactly — the two lists are the
44/// source of cascade order, and they must stay in lock-step or the
45/// served bundle will silently drift from what contributors author.
46///
47/// Project overrides happen via `Admin::theme(...)` (CSS custom
48/// properties) rather than an asset override, so we don't expose a
49/// disk path here.
50const ADMIN_CSS: &str = concat!(
51    // ---- tokens -----------------------------------------------
52    include_str!("../../assets/static/admin/tokens/colors.css"),
53    "\n",
54    include_str!("../../assets/static/admin/tokens/spacing.css"),
55    "\n",
56    include_str!("../../assets/static/admin/tokens/radius.css"),
57    "\n",
58    include_str!("../../assets/static/admin/tokens/shadows.css"),
59    "\n",
60    include_str!("../../assets/static/admin/tokens/typography.css"),
61    "\n",
62    // ---- themes -----------------------------------------------
63    include_str!("../../assets/static/admin/themes/dark.css"),
64    "\n",
65    include_str!("../../assets/static/admin/themes/light.css"),
66    "\n",
67    // ---- base -------------------------------------------------
68    include_str!("../../assets/static/admin/base/reset.css"),
69    "\n",
70    include_str!("../../assets/static/admin/base/base.css"),
71    "\n",
72    include_str!("../../assets/static/admin/base/typography.css"),
73    "\n",
74    include_str!("../../assets/static/admin/base/typography-i18n.css"),
75    "\n",
76    include_str!("../../assets/static/admin/base/utilities.css"),
77    "\n",
78    // ---- layout -----------------------------------------------
79    include_str!("../../assets/static/admin/layout/shell.css"),
80    "\n",
81    include_str!("../../assets/static/admin/layout/topbar.css"),
82    "\n",
83    include_str!("../../assets/static/admin/layout/sidebar.css"),
84    "\n",
85    include_str!("../../assets/static/admin/layout/footer.css"),
86    "\n",
87    // ---- components -------------------------------------------
88    include_str!("../../assets/static/admin/components/cards.css"),
89    "\n",
90    include_str!("../../assets/static/admin/components/buttons.css"),
91    "\n",
92    include_str!("../../assets/static/admin/components/forms.css"),
93    "\n",
94    include_str!("../../assets/static/admin/components/tables.css"),
95    "\n",
96    include_str!("../../assets/static/admin/components/filters.css"),
97    "\n",
98    include_str!("../../assets/static/admin/components/dropdowns.css"),
99    "\n",
100    include_str!("../../assets/static/admin/components/pagination.css"),
101    "\n",
102    include_str!("../../assets/static/admin/components/pills.css"),
103    "\n",
104    include_str!("../../assets/static/admin/components/flashes.css"),
105    "\n",
106    include_str!("../../assets/static/admin/components/timeline.css"),
107    "\n",
108    include_str!("../../assets/static/admin/components/tabs.css"),
109    "\n",
110    // ---- pages ------------------------------------------------
111    include_str!("../../assets/static/admin/pages/auth.css"),
112    "\n",
113    include_str!("../../assets/static/admin/pages/dashboard.css"),
114    "\n",
115    include_str!("../../assets/static/admin/pages/permissions.css"),
116    "\n",
117    include_str!("../../assets/static/admin/pages/sessions.css"),
118    "\n",
119    include_str!("../../assets/static/admin/pages/errors.css"),
120    "\n",
121    // ---- responsive — mobile-first overrides, last so they win.
122    include_str!("../../assets/static/admin/layout/responsive.css"),
123    "\n",
124    // ---- print ------------------------------------------------
125    include_str!("../../assets/static/admin/print/print.css"),
126);
127
128/// Embedded admin JS (theme toggle + sidebar drawer). ≤200 LOC, no
129/// build step.
130const ADMIN_JS: &str = include_str!("../../assets/static/admin.js");
131
132/// Self-hosted fonts (SIL OFL-1.1, see assets/static/fonts/LICENSE.txt).
133/// Bundling them as bytes keeps the single-binary deploy story intact
134/// and avoids the FOUT/CDN round-trip every consuming app would
135/// otherwise inherit from a Google Fonts <link>.
136///
137/// Latin (identity): Geist Variable wght 100..900. Latin fallback:
138/// Inter Variable (Latin-ext + Cyrillic + Greek + Vietnamese under
139/// one variable file). Mono: Geist Mono Variable. Arabic: Noto
140/// Naskh Arabic Variable wght 400..700 (default reading face) +
141/// Tajawal 400/500/700 (selective geometric accent). International
142/// scripts: Noto Sans Thai + Devanagari (variable, auto-loaded via
143/// unicode-range) + Noto Sans JP/KR/SC (static Regular, lang-gated
144/// to avoid Han Unification shape collisions).
145const FONT_GEIST: &[u8] = include_bytes!("../../assets/static/fonts/Geist-Variable.woff2");
146const FONT_GEIST_MONO: &[u8] = include_bytes!("../../assets/static/fonts/GeistMono-Variable.woff2");
147const FONT_INTER: &[u8] = include_bytes!("../../assets/static/fonts/InterVariable.woff2");
148const FONT_TAJAWAL_REG: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Regular.woff2");
149const FONT_TAJAWAL_MED: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Medium.woff2");
150const FONT_TAJAWAL_BOLD: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Bold.woff2");
151const FONT_NOTO_NASKH_AR: &[u8] =
152    include_bytes!("../../assets/static/fonts/NotoNaskhArabic-Variable.woff2");
153const FONT_NOTO_THAI: &[u8] =
154    include_bytes!("../../assets/static/fonts/NotoSansThai-Variable.woff2");
155const FONT_NOTO_DEVA: &[u8] =
156    include_bytes!("../../assets/static/fonts/NotoSansDevanagari-Variable.woff2");
157const FONT_NOTO_JP: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansJP-Regular.woff2");
158const FONT_NOTO_KR: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansKR-Regular.woff2");
159const FONT_NOTO_SC: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansSC-Regular.woff2");
160
161use super::handlers::{self, AdminCtx};
162use super::render;
163use super::types::Admin;
164
165/// Either an identity + a permission check passed, or any non-Allow
166/// response the route closure should return as-is (a 303 redirect to
167/// /admin/login, a 403 forbidden body, etc.).
168enum Guard {
169    Allow(Identity),
170    Redirect(Response),
171}
172
173/// Paths a user with `must_change_password = TRUE` is allowed to
174/// reach without first completing the forced rotation.
175/// Locked-decision per `DESIGN_R2_ORGANISATIONAL.md` §12.
176///
177/// Exact-path match (no prefix matching). Sub-paths of
178/// `/admin/account/sessions` (e.g. `/admin/account/sessions/revoke`)
179/// are intentionally NOT whitelisted — a user being forced to
180/// rotate may view their active sessions but must finish the
181/// rotation before revoking siblings.
182const MUST_CHANGE_WHITELIST: &[&str] = &[
183    "/admin/must-change-password",
184    "/admin/logout",
185    "/admin/account/sessions",
186];
187
188/// Whether `path` is on the must-change-password whitelist.
189/// Pulled out as a free fn so the rule is unit-testable without a
190/// `Request`. See [`MUST_CHANGE_WHITELIST`] for the contract.
191fn is_must_change_whitelisted_path(path: &str) -> bool {
192    MUST_CHANGE_WHITELIST.contains(&path)
193}
194
195/// Paths reachable when `MfaPolicy::Required` is active and the
196/// user has not yet enrolled (R3 commit #18). Forward-only
197/// enforcement per `DESIGN_R3_MFA.md` D6: existing sessions
198/// continue to work, but every non-whitelisted request from a
199/// not-yet-enrolled user redirects to the enrolment form.
200/// Mirrors [`MUST_CHANGE_WHITELIST`]'s shape so the two
201/// interstitial flows compose identically when both gates fire.
202///
203/// Exact-path match. Sub-paths of `/admin/account/sessions`
204/// (e.g. `/admin/account/sessions/revoke`) are NOT whitelisted
205/// — a user being forced to enrol may view their active
206/// sessions but must finish enrolment before revoking siblings.
207const MFA_ENROLL_WHITELIST: &[&str] = &[
208    "/admin/account/mfa/enroll",
209    "/admin/logout",
210    "/admin/account/sessions",
211];
212
213fn is_mfa_enroll_whitelisted_path(path: &str) -> bool {
214    MFA_ENROLL_WHITELIST.contains(&path)
215}
216
217/// Paths reachable when the user has MFA enrolled but the
218/// current session has not yet been promoted to `mfa_verified`
219/// (the post-password, pre-MFA-verify window from R3 commit
220/// #16's `do_login`). The user can complete the second-factor
221/// verify, log out, or inspect their active sessions — nothing
222/// else.
223///
224/// Exact-path match. See [`MFA_ENROLL_WHITELIST`] for the
225/// rationale around the sessions page.
226const MFA_VERIFY_WHITELIST: &[&str] = &[
227    "/admin/mfa/verify",
228    "/admin/logout",
229    "/admin/account/sessions",
230];
231
232fn is_mfa_verify_whitelisted_path(path: &str) -> bool {
233    MFA_VERIFY_WHITELIST.contains(&path)
234}
235
236/// Whether the active `MfaPolicy` requires MFA for a given
237/// role. Pulled out as a free fn so the rule is unit-testable
238/// without an `Admin` context.
239///
240/// `MfaPolicy::Disabled` / `Optional` → never required.
241/// `MfaPolicy::Required` → required for every role.
242/// `MfaPolicy::RequiredForRoles(roles)` → required iff the
243///   user's role appears in the slice. An empty slice reads
244///   as "no role requires MFA" — equivalent to `Optional`.
245fn mfa_required_for_role(policy: crate::auth::MfaPolicy, role: Role) -> bool {
246    use crate::auth::MfaPolicy;
247    match policy {
248        MfaPolicy::Disabled | MfaPolicy::Optional => false,
249        MfaPolicy::Required => true,
250        MfaPolicy::RequiredForRoles(roles) => roles.contains(&role),
251    }
252}
253
254async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
255    let cookie = match req.header("cookie") {
256        Some(c) => c,
257        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
258    };
259    let token = match auth::session_token_from_cookie(cookie) {
260        Some(t) => t,
261        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
262    };
263    let ident = match auth::identity_from_session(&ctx.db, &token).await? {
264        Some(i) => i,
265        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
266    };
267    if !ident.is_active {
268        return Ok(Guard::Redirect(Response::redirect("/admin/login")));
269    }
270
271    // R2 forced-rotation gate (`DESIGN_R2_ORGANISATIONAL.md` §3.4 +
272    // §9.2). When the flag is set, every authenticated request EXCEPT
273    // the whitelist redirects to `/admin/must-change-password`. The
274    // check sits BEFORE any role gate so even Administrators /
275    // Developers with the flag set are funnelled through.
276    if ident.must_change_password && !is_must_change_whitelisted_path(req.path()) {
277        return Ok(Guard::Redirect(Response::redirect(
278            "/admin/must-change-password",
279        )));
280    }
281
282    // R3 MFA-required gate — forward-only per D6
283    // (`DESIGN_R3_MFA.md` §12.3). When the active MfaPolicy
284    // requires MFA for this user's role AND they have not
285    // enrolled, every non-whitelisted request redirects to the
286    // enrolment form. Existing sessions continue to work; the
287    // redirect kicks in at the NEXT request, not at the moment
288    // the policy flips. This matches R2's must-change-password
289    // shape — see MFA_ENROLL_WHITELIST for the reachable paths.
290    let policy = ctx.admin.active_mfa_policy();
291    if mfa_required_for_role(policy, ident.role)
292        && !ident.mfa_enabled
293        && !is_mfa_enroll_whitelisted_path(req.path())
294    {
295        return Ok(Guard::Redirect(Response::redirect(
296            "/admin/account/mfa/enroll",
297        )));
298    }
299
300    // R3 pending-MFA-verify gate (`DESIGN_R3_MFA.md` §4.2 +
301    // §12.3). When the user has MFA enrolled but the current
302    // session has not yet been promoted to mfa_verified (the
303    // post-password, pre-MFA-verify window from commit #16's
304    // do_login), restrict access to the MFA verify whitelist.
305    // The verify POST handler rotates the session via
306    // promote_session_to_mfa_verified once both factors land.
307    use crate::auth::SessionTrust;
308    if ident.mfa_enabled
309        && ident.trust_level != SessionTrust::MfaVerified
310        && !is_mfa_verify_whitelisted_path(req.path())
311    {
312        return Ok(Guard::Redirect(Response::redirect("/admin/mfa/verify")));
313    }
314
315    Ok(Guard::Allow(ident))
316}
317
318async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
319    match login_guard(ctx, req).await? {
320        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
321        Guard::Allow(ident) => {
322            if ident.role.includes(min) {
323                Ok(Guard::Allow(ident))
324            } else {
325                let body = render::render_forbidden_body(
326                    &ctx.admin,
327                    &ctx.templates,
328                    &ident,
329                    handlers::csrf_token(req),
330                    None,
331                    Some(min.label()),
332                )?;
333                Ok(Guard::Redirect(
334                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
335                ))
336            }
337        }
338    }
339}
340
341async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
342    match role_guard(ctx, req, Role::Staff).await? {
343        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
344        Guard::Allow(ident) => {
345            if ident.role.bypasses_group_checks() {
346                return Ok(Guard::Allow(ident));
347            }
348            if auth::check_permission(&ctx.db, &ident, perm).await? {
349                Ok(Guard::Allow(ident))
350            } else {
351                let body = render::render_forbidden_body(
352                    &ctx.admin,
353                    &ctx.templates,
354                    &ident,
355                    handlers::csrf_token(req),
356                    Some(perm.to_string()),
357                    None,
358                )?;
359                Ok(Guard::Redirect(
360                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
361                ))
362            }
363        }
364    }
365}
366
367/// Pure decision logic for `perm_guard`, factored out so it can be
368/// unit-tested without a `Db`.
369#[cfg(test)]
370fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
371    if !ident.is_active {
372        return false;
373    }
374    if ident.role.bypasses_group_checks() {
375        return true;
376    }
377    perm_held
378}
379
380fn parse_id(raw: Option<&str>) -> Result<i64> {
381    raw.and_then(|s| s.parse().ok())
382        .ok_or_else(|| Error::BadRequest("invalid id".into()))
383}
384
385fn model_name_from_req(req: &Request) -> Result<String> {
386    req.param("admin_name")
387        .map(|s| s.to_string())
388        .ok_or_else(|| Error::BadRequest("missing model".into()))
389}
390
391fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
392    let entry = ctx
393        .admin
394        .find(admin_name)
395        .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
396    let singular = entry.singular_name.to_ascii_lowercase();
397    Ok(format!("{admin_name}.{action}_{singular}"))
398}
399
400/// Pure verdict for the R1 strict-mailer boot guard
401/// (`DESIGN_RECOVERY.md` §12.1). Returns an operator-facing error
402/// string when the policy demands a real mailer but `Admin::new()`'s
403/// default `LogMailer` is still in place; returns `Ok(())`
404/// otherwise.
405///
406/// Detection is deterministic and structural: it reads
407/// [`Admin::has_custom_mailer`] (set whenever
408/// [`Admin::mailer`] has been called). No `Arc::ptr_eq` against a
409/// freshly-constructed `LogMailer`; no environment heuristics; no
410/// hostname checks; no "production mode" guessing — the operator
411/// declares intent by calling `Admin::mailer(...)` (and opts the
412/// policy in via `RecoveryPolicy::strict_mailer_required(true)`).
413///
414/// The framework treats an explicit `Admin::mailer(...)` call as
415/// satisfying the guard even when the supplied mailer is itself a
416/// `LogMailer` — this is the documented escape hatch for projects
417/// that want to silence the guard during a migration window
418/// without yet wiring a real transport.
419/// Best-effort identity resolution for the chrome of an admin
420/// error page. Mirrors what `auth::routes::login_guard` does on
421/// the success path, but is non-fatal: any failure returns `None`
422/// and the error page falls back to the unauthenticated chrome
423/// (just like the original behaviour).
424///
425/// `VISIBILITY_AUDIT.md` A2: without this lookup, every 4xx/5xx
426/// page rendered as if the operator were logged out, so the
427/// operator lost their sidebar + top-bar account links the
428/// moment they hit an erroring route — a navigational dead-end.
429async fn resolve_identity_for_error_page(db: &Db, cookie_header: &str) -> Option<Identity> {
430    let token = auth::session_token_from_cookie(cookie_header)?;
431    let identity = auth::identity_from_session(db, token.as_str())
432        .await
433        .ok()
434        .flatten()?;
435    if !identity.is_active {
436        return None;
437    }
438    Some(identity)
439}
440
441fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
442    if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
443        Err(
444            "rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
445             was registered via Admin::mailer(...).\n\n\
446             The framework's default LogMailer writes recovery emails to log::info! instead \
447             of sending them, which is unsuitable for production. Recovery routes are NOT \
448             registered with this configuration.\n\n\
449             To resolve, choose one:\n\
450              (a) register a real mailer before calling register_admin_routes:\n\
451                  Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
452              (b) opt the policy out of strict mode (the framework default — dev / CI / \
453                  testing baseline):\n\
454                  RecoveryPolicy::strict_mailer_required(false)\n\n\
455             See DESIGN_RECOVERY.md §12.1 for the contract."
456                .to_string(),
457        )
458    } else {
459        Ok(())
460    }
461}
462
463// public:
464pub fn register_admin_routes(
465    router: Router,
466    admin: Admin,
467    db: Db,
468    templates: Arc<Templates>,
469) -> Router {
470    // R1 commit #9 — strict-mailer boot guard. Runs BEFORE any
471    // route registration so a misconfigured deployment fails
472    // loudly at startup rather than registering recovery routes
473    // against a production-unsafe default mailer
474    // (`DESIGN_RECOVERY.md` §12.1). The check is structural: see
475    // [`strict_mailer_guard_check`] for why we don't do
476    // pointer-equality tricks against the default LogMailer.
477    if let Err(msg) = strict_mailer_guard_check(&admin) {
478        panic!("{msg}");
479    }
480
481    let ctx = Arc::new(AdminCtx::new(
482        Arc::new(admin),
483        db.clone(),
484        templates.clone(),
485    ));
486
487    // Bespoke user/group pages share the same DB / templates / Admin
488    // arc but live in their own ctx type with the same shape.
489    let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
490        admin: ctx.admin.clone(),
491        db,
492        templates,
493    });
494
495    // Render `Err(_)` from /admin/* handlers as styled HTML instead of
496    // the framework default `text/plain`. Non-admin paths bubble
497    // through unchanged so JSON / curl consumers still get the text
498    // body. `Error::Forbidden` (handled by `role_guard` via
499    // `admin/forbidden.html`) and login-required redirects come
500    // through as `Ok` responses and bypass this branch.
501    //
502    // The middleware also resolves the operator's identity from the
503    // session cookie BEFORE handing off, so the error page renders
504    // with the same chrome (sidebar, top-bar actor, "Log out") the
505    // operator was using when they hit the failing route. Without
506    // this lookup the 404 page rendered as if the operator were
507    // unauthenticated — a navigational dead-end documented as
508    // `VISIBILITY_AUDIT.md` finding A2.
509    let err_admin = ctx.admin.clone();
510    let err_templates = ctx.templates.clone();
511    let err_db = ctx.db.clone();
512    let router = router.middleware(move |req, next| {
513        let admin = err_admin.clone();
514        let templates = err_templates.clone();
515        let db = err_db.clone();
516        Box::pin(async move {
517            let is_admin_path = req.path().starts_with("/admin");
518            // Capture the cookie header BEFORE moving `req` into
519            // `next.run` so the error branch can re-resolve the
520            // operator's identity. The auth middleware sits inside
521            // this one; the request is consumed by `next.run` before
522            // we get to the `Err` branch.
523            let cookie_header = if is_admin_path {
524                req.header("cookie").map(|s| s.to_string())
525            } else {
526                None
527            };
528            let result = next.run(req).await;
529            match result {
530                Ok(resp) => Ok(resp),
531                Err(err) if is_admin_path => {
532                    let identity = match cookie_header.as_deref() {
533                        Some(cookie) => resolve_identity_for_error_page(&db, cookie).await,
534                        None => None,
535                    };
536                    Ok(render::render_admin_error_response(
537                        &admin,
538                        &templates,
539                        identity.as_ref(),
540                        err.status(),
541                        err.client_message().to_string(),
542                    ))
543                }
544                Err(err) => Err(err),
545            }
546        })
547    });
548
549    // Embedded stylesheet + JS. The bytes are baked into the binary
550    // so single-binary deploy is preserved. CSS/JS use `no-cache`
551    // (revalidate every request) so theme + design tweaks roll out the
552    // moment the binary restarts; fonts (next block) keep their long
553    // immutable cache because their bytes never change per release.
554    let router = router.get("/static/admin.css", |_req| async move {
555        Ok(Response::new(
556            hyper::StatusCode::OK,
557            bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
558        )
559        .with_header("content-type", "text/css; charset=utf-8")
560        .with_header("cache-control", "no-cache, must-revalidate"))
561    });
562    let router = router.get("/static/admin.js", |_req| async move {
563        Ok(Response::new(
564            hyper::StatusCode::OK,
565            bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
566        )
567        .with_header("content-type", "application/javascript; charset=utf-8")
568        .with_header("cache-control", "no-cache, must-revalidate"))
569    });
570
571    // Self-hosted fonts. Cache aggressively: file contents are
572    // immutable per build, so a 1-year cache is safe — the binary
573    // ships a fresh copy on the next release.
574    fn font_response(bytes: &'static [u8]) -> Response {
575        Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
576            .with_header("content-type", "font/woff2")
577            .with_header("cache-control", "public, max-age=31536000, immutable")
578    }
579    let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
580        Ok(font_response(FONT_GEIST))
581    });
582    let router = router.get(
583        "/static/fonts/GeistMono-Variable.woff2",
584        |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
585    );
586    let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
587        Ok(font_response(FONT_TAJAWAL_REG))
588    });
589    let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
590        Ok(font_response(FONT_TAJAWAL_MED))
591    });
592    let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
593        Ok(font_response(FONT_TAJAWAL_BOLD))
594    });
595    let router = router.get(
596        "/static/fonts/NotoNaskhArabic-Variable.woff2",
597        |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
598    );
599    let router = router.get("/static/fonts/InterVariable.woff2", |_req| async move {
600        Ok(font_response(FONT_INTER))
601    });
602    let router = router.get(
603        "/static/fonts/NotoSansThai-Variable.woff2",
604        |_req| async move { Ok(font_response(FONT_NOTO_THAI)) },
605    );
606    let router = router.get(
607        "/static/fonts/NotoSansDevanagari-Variable.woff2",
608        |_req| async move { Ok(font_response(FONT_NOTO_DEVA)) },
609    );
610    let router = router.get("/static/fonts/NotoSansJP-Regular.woff2", |_req| async move {
611        Ok(font_response(FONT_NOTO_JP))
612    });
613    let router = router.get("/static/fonts/NotoSansKR-Regular.woff2", |_req| async move {
614        Ok(font_response(FONT_NOTO_KR))
615    });
616    let router = router.get("/static/fonts/NotoSansSC-Regular.woff2", |_req| async move {
617        Ok(font_response(FONT_NOTO_SC))
618    });
619
620    // Public: login/logout.
621    let c = ctx.clone();
622    let router = router.get("/admin/login", move |req| {
623        let c = c.clone();
624        async move { handlers::show_login(&c, req).await }
625    });
626
627    let c = ctx.clone();
628    let router = router.post("/admin/login", move |req| {
629        let c = c.clone();
630        async move { handlers::do_login(&c, req).await }
631    });
632
633    let c = ctx.clone();
634    let router = router.post("/admin/logout", move |req| {
635        let c = c.clone();
636        async move { handlers::do_logout(&c, req).await }
637    });
638
639    // === R1 recovery routes ====================================
640    //
641    // MUST be registered BEFORE the `/admin/:admin_name` model
642    // wildcards lower down — without that ordering, a request to
643    // `/admin/forgot-password` would match `:admin_name =
644    // "forgot-password"` and route into the model CRUD handler.
645    //
646    // Recovery state (the rate-limit buckets) is built once here
647    // and cloned into each route closure so the buckets persist
648    // for the process lifetime. No global / static / OnceLock —
649    // the Arc lives in the closures.
650    //
651    // Strict-mailer boot guard already ran at the top of this fn
652    // (would have panicked if misconfigured); reaching this block
653    // means we have the operator's blessing to wire recovery.
654
655    let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
656        &ctx.admin,
657    ));
658
659    let c = ctx.clone();
660    let router = router.get("/admin/forgot-password", move |req| {
661        let c = c.clone();
662        async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
663    });
664
665    let c = ctx.clone();
666    let rs = recovery_state.clone();
667    let router = router.post("/admin/forgot-password", move |req| {
668        let c = c.clone();
669        let rs = rs.clone();
670        async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
671    });
672
673    let c = ctx.clone();
674    let router = router.get("/admin/forgot-password/sent", move |req| {
675        let c = c.clone();
676        async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
677    });
678
679    let c = ctx.clone();
680    let router = router.get("/admin/reset-password/:token", move |req| {
681        let c = c.clone();
682        async move {
683            let token = req
684                .param("token")
685                .ok_or_else(|| Error::BadRequest("missing token".into()))?
686                .to_string();
687            super::recovery_handlers::show_reset_password(&c, &req, &token).await
688        }
689    });
690
691    let c = ctx.clone();
692    let rs = recovery_state.clone();
693    let router = router.post("/admin/reset-password/:token", move |req| {
694        let c = c.clone();
695        let rs = rs.clone();
696        async move {
697            let token = req
698                .param("token")
699                .ok_or_else(|| Error::BadRequest("missing token".into()))?
700                .to_string();
701            super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
702        }
703    });
704
705    // Dashboard — Staff floor. User-tier sees the forbidden page.
706    let c = ctx.clone();
707    let router = router.get("/admin", move |req| {
708        let c = c.clone();
709        async move {
710            match role_guard(&c, &req, Role::Staff).await? {
711                Guard::Redirect(r) => Ok(r),
712                Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
713            }
714        }
715    });
716
717    // Global history log (admin-only; high-signal page).
718    let c = ctx.clone();
719    let router = router.get("/admin/history", move |req| {
720        let c = c.clone();
721        async move {
722            match role_guard(&c, &req, Role::Administrator).await? {
723                Guard::Redirect(r) => Ok(r),
724                Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
725            }
726        }
727    });
728
729    // Self-service active-sessions listing (R0). Any logged-in user
730    // (User-tier and above) can see their own active sessions.
731    let c = ctx.clone();
732    let router = router.get("/admin/account/sessions", move |req| {
733        let c = c.clone();
734        async move {
735            match role_guard(&c, &req, Role::User).await? {
736                Guard::Redirect(r) => Ok(r),
737                Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
738            }
739        }
740    });
741
742    // R1 commit #10 — active-sessions revoke buttons. All three
743    // POST routes go through `auth::invalidate_sessions` (Doctrine
744    // 22) and write `AuditEvent::SessionsRevokedSelf` per revoked
745    // id. The `/revoke-others` and `/revoke-all` literal segments
746    // sit at depth-4 while `:id/revoke` sits at depth-5, so segment
747    // count alone disambiguates them — no explicit ordering
748    // constraint between the three.
749    let c = ctx.clone();
750    let router = router.post("/admin/account/sessions/revoke-others", move |req| {
751        let c = c.clone();
752        async move {
753            match role_guard(&c, &req, Role::User).await? {
754                Guard::Redirect(r) => Ok(r),
755                Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
756            }
757        }
758    });
759
760    let c = ctx.clone();
761    let router = router.post("/admin/account/sessions/revoke-all", move |req| {
762        let c = c.clone();
763        async move {
764            match role_guard(&c, &req, Role::User).await? {
765                Guard::Redirect(r) => Ok(r),
766                Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
767            }
768        }
769    });
770
771    let c = ctx.clone();
772    let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
773        let c = c.clone();
774        async move {
775            match role_guard(&c, &req, Role::User).await? {
776                Guard::Redirect(r) => Ok(r),
777                Guard::Allow(ident) => {
778                    let id = parse_id(req.param("id"))?;
779                    handlers::do_revoke_session(&c, ident, req, id).await
780                }
781            }
782        }
783    });
784
785    // Self-service password change. Any logged-in user (User-tier and
786    // above). User-tier can change their own password even though
787    // they can't access the dashboard.
788    let c = ctx.clone();
789    let router = router.get("/admin/password_change", move |req| {
790        let c = c.clone();
791        async move {
792            match role_guard(&c, &req, Role::User).await? {
793                Guard::Redirect(r) => Ok(r),
794                Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
795            }
796        }
797    });
798    let c = ctx.clone();
799    let router = router.post("/admin/password_change", move |req| {
800        let c = c.clone();
801        async move {
802            match role_guard(&c, &req, Role::User).await? {
803                Guard::Redirect(r) => Ok(r),
804                Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
805            }
806        }
807    });
808
809    // === R2 re-auth wall (R2 commit #11) ====================================
810    //
811    // Standalone wall: any authenticated user can promote their own
812    // session into the elevated band by re-entering their password.
813    // The handler validates `return_to` strictly (only `/admin*`
814    // paths; see `admin_recovery_handlers::validate_return_to`).
815    // Any role from User-tier upward.
816
817    let c = ctx.clone();
818    let router = router.get("/admin/reauth", move |req| {
819        let c = c.clone();
820        async move {
821            match role_guard(&c, &req, Role::User).await? {
822                Guard::Redirect(r) => Ok(r),
823                Guard::Allow(ident) => {
824                    super::admin_recovery_handlers::show_reauth(&c, ident, &req).await
825                }
826            }
827        }
828    });
829
830    let c = ctx.clone();
831    let router = router.post("/admin/reauth", move |req| {
832        let c = c.clone();
833        async move {
834            match role_guard(&c, &req, Role::User).await? {
835                Guard::Redirect(r) => Ok(r),
836                Guard::Allow(ident) => {
837                    super::admin_recovery_handlers::do_reauth(&c, ident, req).await
838                }
839            }
840        }
841    });
842
843    // === R2 forced password rotation (R2 commit #12) ========================
844    //
845    // The `must_change_password` interstitial is the only writeable
846    // surface a user can reach while their flag is TRUE. The path is
847    // on `MUST_CHANGE_WHITELIST`; the `login_guard` redirect therefore
848    // skips it (otherwise the rotation would be unreachable). Role::User
849    // matches: any authenticated user can be forced to rotate, even a
850    // User-tier account that can't access the dashboard.
851
852    let c = ctx.clone();
853    let router = router.get("/admin/must-change-password", move |req| {
854        let c = c.clone();
855        async move {
856            match role_guard(&c, &req, Role::User).await? {
857                Guard::Redirect(r) => Ok(r),
858                Guard::Allow(ident) => {
859                    super::admin_recovery_handlers::show_must_change_password(&c, ident, &req).await
860                }
861            }
862        }
863    });
864
865    let c = ctx.clone();
866    let router = router.post("/admin/must-change-password", move |req| {
867        let c = c.clone();
868        async move {
869            match role_guard(&c, &req, Role::User).await? {
870                Guard::Redirect(r) => Ok(r),
871                Guard::Allow(ident) => {
872                    super::admin_recovery_handlers::do_must_change_password(&c, ident, req).await
873                }
874            }
875        }
876    });
877
878    // === R3 MFA surface (R3 commits #12-#15) ================================
879    //
880    // Eight routes:
881    //   /admin/mfa/verify                        — login second factor (#12)
882    //   /admin/account/mfa/enroll                — provision + confirm (#13)
883    //   /admin/account/mfa/regenerate-codes      — atomic batch swap   (#14)
884    //   /admin/account/mfa/disable               — self-disable        (#15)
885    //
886    // All gated by `Role::User` — every authenticated user can manage
887    // their own MFA. The /admin/mfa/verify path is on
888    // `MFA_VERIFY_WHITELIST`; the enrol path is on
889    // `MFA_ENROLL_WHITELIST` — so `login_guard` does NOT redirect
890    // away from these routes even when the user is in the pending-
891    // verify or required-enrol state. Otherwise the interstitial
892    // pages would be unreachable.
893
894    // --- /admin/mfa/verify (R3 commit #12) ---
895    let c = ctx.clone();
896    let router = router.get("/admin/mfa/verify", move |req| {
897        let c = c.clone();
898        async move {
899            match role_guard(&c, &req, Role::User).await? {
900                Guard::Redirect(r) => Ok(r),
901                Guard::Allow(ident) => super::mfa_handlers::show_verify(&c, ident, &req).await,
902            }
903        }
904    });
905
906    let c = ctx.clone();
907    let router = router.post("/admin/mfa/verify", move |req| {
908        let c = c.clone();
909        async move {
910            match role_guard(&c, &req, Role::User).await? {
911                Guard::Redirect(r) => Ok(r),
912                Guard::Allow(ident) => super::mfa_handlers::do_verify(&c, ident, req).await,
913            }
914        }
915    });
916
917    // --- /admin/account/mfa/enroll (R3 commit #13) ---
918    let c = ctx.clone();
919    let router = router.get("/admin/account/mfa/enroll", move |req| {
920        let c = c.clone();
921        async move {
922            match role_guard(&c, &req, Role::User).await? {
923                Guard::Redirect(r) => Ok(r),
924                Guard::Allow(ident) => super::mfa_handlers::show_enroll(&c, ident, &req).await,
925            }
926        }
927    });
928
929    let c = ctx.clone();
930    let router = router.post("/admin/account/mfa/enroll", move |req| {
931        let c = c.clone();
932        async move {
933            match role_guard(&c, &req, Role::User).await? {
934                Guard::Redirect(r) => Ok(r),
935                Guard::Allow(ident) => super::mfa_handlers::do_enroll(&c, ident, req).await,
936            }
937        }
938    });
939
940    // --- /admin/account/mfa/regenerate-codes (R3 commit #14) ---
941    let c = ctx.clone();
942    let router = router.get("/admin/account/mfa/regenerate-codes", move |req| {
943        let c = c.clone();
944        async move {
945            match role_guard(&c, &req, Role::User).await? {
946                Guard::Redirect(r) => Ok(r),
947                Guard::Allow(ident) => super::mfa_handlers::show_regenerate(&c, ident, &req).await,
948            }
949        }
950    });
951
952    let c = ctx.clone();
953    let router = router.post("/admin/account/mfa/regenerate-codes", move |req| {
954        let c = c.clone();
955        async move {
956            match role_guard(&c, &req, Role::User).await? {
957                Guard::Redirect(r) => Ok(r),
958                Guard::Allow(ident) => super::mfa_handlers::do_regenerate(&c, ident, req).await,
959            }
960        }
961    });
962
963    // --- /admin/account/mfa/disable (R3 commit #15) ---
964    let c = ctx.clone();
965    let router = router.get("/admin/account/mfa/disable", move |req| {
966        let c = c.clone();
967        async move {
968            match role_guard(&c, &req, Role::User).await? {
969                Guard::Redirect(r) => Ok(r),
970                Guard::Allow(ident) => super::mfa_handlers::show_disable(&c, ident, &req).await,
971            }
972        }
973    });
974
975    let c = ctx.clone();
976    let router = router.post("/admin/account/mfa/disable", move |req| {
977        let c = c.clone();
978        async move {
979            match role_guard(&c, &req, Role::User).await? {
980                Guard::Redirect(r) => Ok(r),
981                Guard::Allow(ident) => super::mfa_handlers::do_disable(&c, ident, req).await,
982            }
983        }
984    });
985
986    // --- Built-in users admin (admin-only) ---
987    let c = ctx.clone();
988    let ac = auth_ctx.clone();
989    let router = router.get("/admin/users", move |req| {
990        let c = c.clone();
991        let ac = ac.clone();
992        async move {
993            match role_guard(&c, &req, Role::Administrator).await? {
994                Guard::Redirect(r) => Ok(r),
995                Guard::Allow(ident) => {
996                    super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
997                }
998            }
999        }
1000    });
1001
1002    let c = ctx.clone();
1003    let ac = auth_ctx.clone();
1004    let router = router.get("/admin/users/new", move |req| {
1005        let c = c.clone();
1006        let ac = ac.clone();
1007        async move {
1008            match role_guard(&c, &req, Role::Administrator).await? {
1009                Guard::Redirect(r) => Ok(r),
1010                Guard::Allow(ident) => {
1011                    super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
1012                }
1013            }
1014        }
1015    });
1016
1017    let c = ctx.clone();
1018    let ac = auth_ctx.clone();
1019    let router = router.post("/admin/users/new", move |req| {
1020        let c = c.clone();
1021        let ac = ac.clone();
1022        async move {
1023            match role_guard(&c, &req, Role::Administrator).await? {
1024                Guard::Redirect(r) => Ok(r),
1025                Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
1026            }
1027        }
1028    });
1029
1030    let c = ctx.clone();
1031    let ac = auth_ctx.clone();
1032    let router = router.get("/admin/users/:id/edit", move |req| {
1033        let c = c.clone();
1034        let ac = ac.clone();
1035        async move {
1036            match role_guard(&c, &req, Role::Administrator).await? {
1037                Guard::Redirect(r) => Ok(r),
1038                Guard::Allow(ident) => {
1039                    let id = parse_id(req.param("id"))?;
1040                    super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
1041                }
1042            }
1043        }
1044    });
1045
1046    let c = ctx.clone();
1047    let ac = auth_ctx.clone();
1048    let router = router.post("/admin/users/:id/edit", move |req| {
1049        let c = c.clone();
1050        let ac = ac.clone();
1051        async move {
1052            match role_guard(&c, &req, Role::Administrator).await? {
1053                Guard::Redirect(r) => Ok(r),
1054                Guard::Allow(ident) => {
1055                    let id = parse_id(req.param("id"))?;
1056                    super::builtin::do_user_edit(&ac, ident, id, req).await
1057                }
1058            }
1059        }
1060    });
1061
1062    let c = ctx.clone();
1063    let ac = auth_ctx.clone();
1064    let router = router.get("/admin/users/:id/delete", move |req| {
1065        let c = c.clone();
1066        let ac = ac.clone();
1067        async move {
1068            match role_guard(&c, &req, Role::Administrator).await? {
1069                Guard::Redirect(r) => Ok(r),
1070                Guard::Allow(ident) => {
1071                    let id = parse_id(req.param("id"))?;
1072                    super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
1073                        .await
1074                }
1075            }
1076        }
1077    });
1078
1079    let c = ctx.clone();
1080    let ac = auth_ctx.clone();
1081    let router = router.post("/admin/users/:id/delete", move |req| {
1082        let c = c.clone();
1083        let ac = ac.clone();
1084        async move {
1085            match role_guard(&c, &req, Role::Administrator).await? {
1086                Guard::Redirect(r) => Ok(r),
1087                Guard::Allow(ident) => {
1088                    let id = parse_id(req.param("id"))?;
1089                    super::builtin::do_user_delete(&ac, ident, id, req).await
1090                }
1091            }
1092        }
1093    });
1094
1095    // === R2 admin-driven recovery routes ====================================
1096    //
1097    // Registered alongside the existing `/admin/users/:id/...` cluster
1098    // (per `DESIGN_R2_ORGANISATIONAL.md` §7.2 — user-related cluster
1099    // contiguous). All gated `Role::Administrator`; the cross-rank
1100    // safety check + the re-auth wall are enforced INSIDE the
1101    // handlers (commits #15 / #16) so a Supervisor probe doesn't even
1102    // reach the form.
1103    //
1104    // Insertion-order note: these are 4-segment routes, so the
1105    // 3-segment `/admin/users/:id` read-only view further down doesn't
1106    // conflict regardless of order. Placing them before the 3-segment
1107    // view keeps the user routes lexically clustered.
1108
1109    // GET /admin/users/:id/reset-password — admin reset form (R2 #15).
1110    let c = ctx.clone();
1111    let router = router.get("/admin/users/:id/reset-password", move |req| {
1112        let c = c.clone();
1113        async move {
1114            match role_guard(&c, &req, Role::Administrator).await? {
1115                Guard::Redirect(r) => Ok(r),
1116                Guard::Allow(ident) => {
1117                    let id = parse_id(req.param("id"))?;
1118                    super::admin_recovery_handlers::show_admin_reset_password(&c, ident, id, &req)
1119                        .await
1120                }
1121            }
1122        }
1123    });
1124
1125    // POST /admin/users/:id/reset-password — apply admin reset (R2 #15).
1126    let c = ctx.clone();
1127    let router = router.post("/admin/users/:id/reset-password", move |req| {
1128        let c = c.clone();
1129        async move {
1130            match role_guard(&c, &req, Role::Administrator).await? {
1131                Guard::Redirect(r) => Ok(r),
1132                Guard::Allow(ident) => {
1133                    let id = parse_id(req.param("id"))?;
1134                    super::admin_recovery_handlers::do_admin_reset_password(&c, ident, id, req)
1135                        .await
1136                }
1137            }
1138        }
1139    });
1140
1141    // GET /admin/users/:id/lock — lock confirmation form (R2 #16).
1142    let c = ctx.clone();
1143    let router = router.get("/admin/users/:id/lock", move |req| {
1144        let c = c.clone();
1145        async move {
1146            match role_guard(&c, &req, Role::Administrator).await? {
1147                Guard::Redirect(r) => Ok(r),
1148                Guard::Allow(ident) => {
1149                    let id = parse_id(req.param("id"))?;
1150                    super::admin_recovery_handlers::show_lock_user(&c, ident, id, &req).await
1151                }
1152            }
1153        }
1154    });
1155
1156    // POST /admin/users/:id/lock — apply manual lock (R2 #16).
1157    let c = ctx.clone();
1158    let router = router.post("/admin/users/:id/lock", move |req| {
1159        let c = c.clone();
1160        async move {
1161            match role_guard(&c, &req, Role::Administrator).await? {
1162                Guard::Redirect(r) => Ok(r),
1163                Guard::Allow(ident) => {
1164                    let id = parse_id(req.param("id"))?;
1165                    super::admin_recovery_handlers::do_lock_user(&c, ident, id, req).await
1166                }
1167            }
1168        }
1169    });
1170
1171    // GET /admin/users/:id/unlock — unlock confirmation form (R2 #16).
1172    let c = ctx.clone();
1173    let router = router.get("/admin/users/:id/unlock", move |req| {
1174        let c = c.clone();
1175        async move {
1176            match role_guard(&c, &req, Role::Administrator).await? {
1177                Guard::Redirect(r) => Ok(r),
1178                Guard::Allow(ident) => {
1179                    let id = parse_id(req.param("id"))?;
1180                    super::admin_recovery_handlers::show_unlock_user(&c, ident, id, &req).await
1181                }
1182            }
1183        }
1184    });
1185
1186    // POST /admin/users/:id/unlock — clear lock (R2 #16).
1187    let c = ctx.clone();
1188    let router = router.post("/admin/users/:id/unlock", move |req| {
1189        let c = c.clone();
1190        async move {
1191            match role_guard(&c, &req, Role::Administrator).await? {
1192                Guard::Redirect(r) => Ok(r),
1193                Guard::Allow(ident) => {
1194                    let id = parse_id(req.param("id"))?;
1195                    super::admin_recovery_handlers::do_unlock_user(&c, ident, id, req).await
1196                }
1197            }
1198        }
1199    });
1200
1201    // GET /admin/users/:id/revoke-sessions — revoke confirmation form
1202    // (R2 #16).
1203    let c = ctx.clone();
1204    let router = router.get("/admin/users/:id/revoke-sessions", move |req| {
1205        let c = c.clone();
1206        async move {
1207            match role_guard(&c, &req, Role::Administrator).await? {
1208                Guard::Redirect(r) => Ok(r),
1209                Guard::Allow(ident) => {
1210                    let id = parse_id(req.param("id"))?;
1211                    super::admin_recovery_handlers::show_admin_revoke_sessions(&c, ident, id, &req)
1212                        .await
1213                }
1214            }
1215        }
1216    });
1217
1218    // POST /admin/users/:id/revoke-sessions — revoke all sessions (R2 #16).
1219    let c = ctx.clone();
1220    let router = router.post("/admin/users/:id/revoke-sessions", move |req| {
1221        let c = c.clone();
1222        async move {
1223            match role_guard(&c, &req, Role::Administrator).await? {
1224                Guard::Redirect(r) => Ok(r),
1225                Guard::Allow(ident) => {
1226                    let id = parse_id(req.param("id"))?;
1227                    super::admin_recovery_handlers::do_admin_revoke_sessions(&c, ident, id, req)
1228                        .await
1229                }
1230            }
1231        }
1232    });
1233
1234    // Read-only user profile view. MUST be registered AFTER
1235    // `/admin/users/new` and the `:id/edit` + `:id/delete` routes
1236    // above: the router matches in insertion order, and `:id` is a
1237    // wildcard that would happily swallow "new" or extra path
1238    // segments. Putting this last preserves the more-specific routes'
1239    // priority.
1240    let c = ctx.clone();
1241    let ac = auth_ctx.clone();
1242    let router = router.get("/admin/users/:id", move |req| {
1243        let c = c.clone();
1244        let ac = ac.clone();
1245        async move {
1246            match role_guard(&c, &req, Role::Administrator).await? {
1247                Guard::Redirect(r) => Ok(r),
1248                Guard::Allow(ident) => {
1249                    let id = parse_id(req.param("id"))?;
1250                    let q = req.query();
1251                    let tab = q.get("tab").map(|s| s.to_string());
1252                    let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
1253                    super::builtin::show_user_view(
1254                        &ac,
1255                        ident,
1256                        id,
1257                        handlers::csrf_token(&req),
1258                        tab,
1259                        page,
1260                    )
1261                    .await
1262                }
1263            }
1264        }
1265    });
1266
1267    // --- Built-in groups admin (admin-only) ---
1268    let c = ctx.clone();
1269    let ac = auth_ctx.clone();
1270    let router = router.get("/admin/groups", move |req| {
1271        let c = c.clone();
1272        let ac = ac.clone();
1273        async move {
1274            match role_guard(&c, &req, Role::Administrator).await? {
1275                Guard::Redirect(r) => Ok(r),
1276                Guard::Allow(ident) => {
1277                    super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
1278                }
1279            }
1280        }
1281    });
1282
1283    let c = ctx.clone();
1284    let ac = auth_ctx.clone();
1285    let router = router.get("/admin/groups/new", move |req| {
1286        let c = c.clone();
1287        let ac = ac.clone();
1288        async move {
1289            match role_guard(&c, &req, Role::Administrator).await? {
1290                Guard::Redirect(r) => Ok(r),
1291                Guard::Allow(ident) => {
1292                    super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
1293                }
1294            }
1295        }
1296    });
1297
1298    let c = ctx.clone();
1299    let ac = auth_ctx.clone();
1300    let router = router.post("/admin/groups/new", move |req| {
1301        let c = c.clone();
1302        let ac = ac.clone();
1303        async move {
1304            match role_guard(&c, &req, Role::Administrator).await? {
1305                Guard::Redirect(r) => Ok(r),
1306                Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
1307            }
1308        }
1309    });
1310
1311    let c = ctx.clone();
1312    let ac = auth_ctx.clone();
1313    let router = router.get("/admin/groups/:id/edit", move |req| {
1314        let c = c.clone();
1315        let ac = ac.clone();
1316        async move {
1317            match role_guard(&c, &req, Role::Administrator).await? {
1318                Guard::Redirect(r) => Ok(r),
1319                Guard::Allow(ident) => {
1320                    let id = parse_id(req.param("id"))?;
1321                    super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
1322                        .await
1323                }
1324            }
1325        }
1326    });
1327
1328    let c = ctx.clone();
1329    let ac = auth_ctx.clone();
1330    let router = router.post("/admin/groups/:id/edit", move |req| {
1331        let c = c.clone();
1332        let ac = ac.clone();
1333        async move {
1334            match role_guard(&c, &req, Role::Administrator).await? {
1335                Guard::Redirect(r) => Ok(r),
1336                Guard::Allow(ident) => {
1337                    let id = parse_id(req.param("id"))?;
1338                    super::builtin::do_group_edit(&ac, ident, id, req).await
1339                }
1340            }
1341        }
1342    });
1343
1344    let c = ctx.clone();
1345    let ac = auth_ctx.clone();
1346    let router = router.get("/admin/groups/:id/delete", move |req| {
1347        let c = c.clone();
1348        let ac = ac.clone();
1349        async move {
1350            match role_guard(&c, &req, Role::Administrator).await? {
1351                Guard::Redirect(r) => Ok(r),
1352                Guard::Allow(ident) => {
1353                    let id = parse_id(req.param("id"))?;
1354                    super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
1355                        .await
1356                }
1357            }
1358        }
1359    });
1360
1361    let c = ctx.clone();
1362    let ac = auth_ctx.clone();
1363    let router = router.post("/admin/groups/:id/delete", move |req| {
1364        let c = c.clone();
1365        let ac = ac.clone();
1366        async move {
1367            match role_guard(&c, &req, Role::Administrator).await? {
1368                Guard::Redirect(r) => Ok(r),
1369                Guard::Allow(ident) => {
1370                    let id = parse_id(req.param("id"))?;
1371                    super::builtin::do_group_delete(&ac, ident, id, req).await
1372                }
1373            }
1374        }
1375    });
1376
1377    // Per-model list — needs `view` permission.
1378    let c = ctx.clone();
1379    let router = router.get("/admin/:admin_name", move |req| {
1380        let c = c.clone();
1381        async move {
1382            let name = model_name_from_req(&req)?;
1383            let perm = perm_for(&c, &name, "view")?;
1384            match perm_guard(&c, &req, &perm).await? {
1385                Guard::Redirect(r) => Ok(r),
1386                Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
1387            }
1388        }
1389    });
1390
1391    // Create.
1392    let c = ctx.clone();
1393    let router = router.get("/admin/:admin_name/new", move |req| {
1394        let c = c.clone();
1395        async move {
1396            let name = model_name_from_req(&req)?;
1397            let perm = perm_for(&c, &name, "add")?;
1398            match perm_guard(&c, &req, &perm).await? {
1399                Guard::Redirect(r) => Ok(r),
1400                Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
1401            }
1402        }
1403    });
1404    let c = ctx.clone();
1405    let router = router.post("/admin/:admin_name/new", move |req| {
1406        let c = c.clone();
1407        async move {
1408            let name = model_name_from_req(&req)?;
1409            let perm = perm_for(&c, &name, "add")?;
1410            match perm_guard(&c, &req, &perm).await? {
1411                Guard::Redirect(r) => Ok(r),
1412                Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
1413            }
1414        }
1415    });
1416
1417    // Edit.
1418    let c = ctx.clone();
1419    let router = router.get("/admin/:admin_name/:id/edit", move |req| {
1420        let c = c.clone();
1421        async move {
1422            let name = model_name_from_req(&req)?;
1423            let perm = perm_for(&c, &name, "change")?;
1424            match perm_guard(&c, &req, &perm).await? {
1425                Guard::Redirect(r) => Ok(r),
1426                Guard::Allow(ident) => {
1427                    let id = parse_id(req.param("id"))?;
1428                    handlers::show_edit_form(&c, ident, &name, id, &req).await
1429                }
1430            }
1431        }
1432    });
1433    let c = ctx.clone();
1434    let router = router.post("/admin/:admin_name/:id/edit", move |req| {
1435        let c = c.clone();
1436        async move {
1437            let name = model_name_from_req(&req)?;
1438            let perm = perm_for(&c, &name, "change")?;
1439            match perm_guard(&c, &req, &perm).await? {
1440                Guard::Redirect(r) => Ok(r),
1441                Guard::Allow(ident) => {
1442                    let id = parse_id(req.param("id"))?;
1443                    handlers::do_update(&c, ident, &name, id, req).await
1444                }
1445            }
1446        }
1447    });
1448
1449    // Per-object history. Read-only; same `view` permission as the
1450    // changelist (if you can list, you can read the audit trail).
1451    let c = ctx.clone();
1452    let router = router.get("/admin/:admin_name/:id/history", move |req| {
1453        let c = c.clone();
1454        async move {
1455            let name = model_name_from_req(&req)?;
1456            let perm = perm_for(&c, &name, "view")?;
1457            match perm_guard(&c, &req, &perm).await? {
1458                Guard::Redirect(r) => Ok(r),
1459                Guard::Allow(ident) => {
1460                    let id = parse_id(req.param("id"))?;
1461                    handlers::show_object_history(&c, ident, &name, id, &req).await
1462                }
1463            }
1464        }
1465    });
1466
1467    // Delete.
1468    let c = ctx.clone();
1469    let router = router.get("/admin/:admin_name/:id/delete", move |req| {
1470        let c = c.clone();
1471        async move {
1472            let name = model_name_from_req(&req)?;
1473            let perm = perm_for(&c, &name, "delete")?;
1474            match perm_guard(&c, &req, &perm).await? {
1475                Guard::Redirect(r) => Ok(r),
1476                Guard::Allow(ident) => {
1477                    let id = parse_id(req.param("id"))?;
1478                    handlers::show_delete_confirm(&c, ident, &name, id, &req).await
1479                }
1480            }
1481        }
1482    });
1483    let c = ctx.clone();
1484    let router = router.post("/admin/:admin_name/:id/delete", move |req| {
1485        let c = c.clone();
1486        async move {
1487            let name = model_name_from_req(&req)?;
1488            let perm = perm_for(&c, &name, "delete")?;
1489            match perm_guard(&c, &req, &perm).await? {
1490                Guard::Redirect(r) => Ok(r),
1491                Guard::Allow(ident) => {
1492                    let id = parse_id(req.param("id"))?;
1493                    handlers::do_delete(&c, ident, &name, id).await
1494                }
1495            }
1496        }
1497    });
1498
1499    // Bulk delete — same permission gate as the per-row delete.
1500    // Two-step flow: first POST renders the confirm page, second POST
1501    // (with `_confirmed=1`) executes. See `handlers::handle_bulk_delete`
1502    // for the full contract.
1503    let c = ctx.clone();
1504    let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
1505        let c = c.clone();
1506        async move {
1507            let name = model_name_from_req(&req)?;
1508            let perm = perm_for(&c, &name, "delete")?;
1509            match perm_guard(&c, &req, &perm).await? {
1510                Guard::Redirect(r) => Ok(r),
1511                Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
1512            }
1513        }
1514    });
1515
1516    // Project-defined bulk actions. Permission gated on `change` —
1517    // bulk actions modify rows but don't delete them (delete has its
1518    // own route). Project-side guard against further write-vs-read
1519    // distinctions belongs inside `execute_bulk_action`.
1520    let c = ctx.clone();
1521    router.post("/admin/:admin_name/bulk/:action", move |req| {
1522        let c = c.clone();
1523        async move {
1524            let name = model_name_from_req(&req)?;
1525            let action = req
1526                .param("action")
1527                .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
1528                .to_string();
1529            let perm = perm_for(&c, &name, "change")?;
1530            match perm_guard(&c, &req, &perm).await? {
1531                Guard::Redirect(r) => Ok(r),
1532                Guard::Allow(ident) => {
1533                    handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
1534                }
1535            }
1536        }
1537    })
1538}
1539
1540#[cfg(test)]
1541mod tests {
1542    use super::*;
1543
1544    fn make_identity(role: Role, is_active: bool) -> Identity {
1545        Identity {
1546            user_id: 42,
1547            email: "test@example.com".into(),
1548            role,
1549            is_active,
1550            is_demo: false,
1551            demo_label: None,
1552            must_change_password: false,
1553            mfa_enabled: false,
1554            trust_level: crate::auth::SessionTrust::Authenticated,
1555        }
1556    }
1557
1558    // role_guard's decision is `Role::includes(min)`. The 25-case
1559    // matrix lives in `auth::role::tests::includes_matrix_…`; the
1560    // cases below pin the most operator-relevant pairings.
1561
1562    #[test]
1563    fn role_guard_decision_admin_meets_staff_floor() {
1564        let id = make_identity(Role::Administrator, true);
1565        assert!(id.role.includes(Role::Staff));
1566    }
1567
1568    #[test]
1569    fn role_guard_decision_user_does_not_meet_staff() {
1570        let id = make_identity(Role::User, true);
1571        assert!(!id.role.includes(Role::Staff));
1572    }
1573
1574    #[test]
1575    fn role_guard_decision_administrator_does_not_meet_developer() {
1576        let id = make_identity(Role::Administrator, true);
1577        assert!(!id.role.includes(Role::Developer));
1578    }
1579
1580    #[test]
1581    fn role_guard_decision_developer_meets_everything() {
1582        let id = make_identity(Role::Developer, true);
1583        for &min in &[
1584            Role::User,
1585            Role::Staff,
1586            Role::Supervisor,
1587            Role::Administrator,
1588            Role::Developer,
1589        ] {
1590            assert!(id.role.includes(min), "Developer should meet {min:?}");
1591        }
1592    }
1593
1594    // ---- perm_guard_verdict matrix --------------------------------------
1595
1596    #[test]
1597    fn perm_guard_admin_short_circuits_without_perm() {
1598        let id = make_identity(Role::Administrator, true);
1599        assert!(perm_guard_verdict(&id, false));
1600    }
1601
1602    #[test]
1603    fn perm_guard_developer_short_circuits_without_perm() {
1604        let id = make_identity(Role::Developer, true);
1605        assert!(perm_guard_verdict(&id, false));
1606    }
1607
1608    #[test]
1609    fn perm_guard_staff_with_perm_passes() {
1610        let id = make_identity(Role::Staff, true);
1611        assert!(perm_guard_verdict(&id, true));
1612    }
1613
1614    #[test]
1615    fn perm_guard_staff_without_perm_denies() {
1616        let id = make_identity(Role::Staff, true);
1617        assert!(!perm_guard_verdict(&id, false));
1618    }
1619
1620    #[test]
1621    fn perm_guard_inactive_admin_denies_even_with_bypass() {
1622        // Defense-in-depth invariant.
1623        let id = make_identity(Role::Administrator, false);
1624        assert!(!perm_guard_verdict(&id, true));
1625    }
1626
1627    #[test]
1628    fn perm_guard_supervisor_without_perm_denies() {
1629        // Supervisor doesn't bypass; needs the per-model perm.
1630        let id = make_identity(Role::Supervisor, true);
1631        assert!(!perm_guard_verdict(&id, false));
1632    }
1633
1634    // ---- strict_mailer_guard_check ----------------------------------------
1635
1636    /// Default `Admin::new()` doesn't override the mailer AND
1637    /// doesn't enable strict mode — the guard passes.
1638    #[test]
1639    fn strict_mailer_guard_passes_for_default_admin() {
1640        let admin = super::super::types::Admin::new();
1641        assert!(strict_mailer_guard_check(&admin).is_ok());
1642    }
1643
1644    /// Strict-mailer mode + default LogMailer = boot guard fires.
1645    /// The error message is operator-actionable.
1646    #[test]
1647    fn strict_mailer_guard_fails_when_required_but_default_mailer() {
1648        use crate::auth::DefaultRecoveryPolicy;
1649        let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
1650            DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1651        ));
1652        let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
1653        assert!(
1654            err.contains("strict_mailer_required"),
1655            "error message must name the policy method: {err}"
1656        );
1657        assert!(
1658            err.contains("Admin::mailer"),
1659            "error message must direct the operator to the fix: {err}"
1660        );
1661    }
1662
1663    /// Strict-mailer mode + project-supplied mailer = guard passes.
1664    /// Note: the explicit override flips the flag even when the
1665    /// supplied value happens to be another LogMailer — the
1666    /// operator's intent is what matters, not the concrete type.
1667    #[test]
1668    fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
1669        use crate::auth::DefaultRecoveryPolicy;
1670        use crate::email::LogMailer;
1671        let admin = super::super::types::Admin::new()
1672            .recovery_policy(std::sync::Arc::new(
1673                DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1674            ))
1675            .mailer(std::sync::Arc::new(LogMailer));
1676        assert!(strict_mailer_guard_check(&admin).is_ok());
1677    }
1678
1679    /// Project NOT in strict mode + default LogMailer = passes
1680    /// (dev / CI / testing baseline).
1681    #[test]
1682    fn strict_mailer_guard_passes_when_strict_mode_disabled() {
1683        let admin = super::super::types::Admin::new();
1684        assert!(strict_mailer_guard_check(&admin).is_ok());
1685    }
1686
1687    // ---- must-change-password whitelist (R2 commit #13) --------------------
1688
1689    #[test]
1690    fn whitelist_accepts_the_three_locked_paths() {
1691        // Locked-decision per DESIGN_R2_ORGANISATIONAL.md §12.
1692        assert!(super::is_must_change_whitelisted_path(
1693            "/admin/must-change-password"
1694        ));
1695        assert!(super::is_must_change_whitelisted_path("/admin/logout"));
1696        assert!(super::is_must_change_whitelisted_path(
1697            "/admin/account/sessions"
1698        ));
1699    }
1700
1701    #[test]
1702    fn whitelist_rejects_subpaths_of_account_sessions() {
1703        // Sub-paths of /admin/account/sessions (revoke buttons) are
1704        // intentionally NOT whitelisted — a user being forced to
1705        // rotate may VIEW their sessions but must finish the
1706        // rotation before revoking siblings.
1707        assert!(!super::is_must_change_whitelisted_path(
1708            "/admin/account/sessions/revoke"
1709        ));
1710        assert!(!super::is_must_change_whitelisted_path(
1711            "/admin/account/sessions/revoke-others"
1712        ));
1713        assert!(!super::is_must_change_whitelisted_path(
1714            "/admin/account/sessions/"
1715        ));
1716    }
1717
1718    #[test]
1719    fn whitelist_rejects_other_admin_paths() {
1720        for path in [
1721            "/admin",
1722            "/admin/",
1723            "/admin/users",
1724            "/admin/users/42",
1725            "/admin/login",
1726            "/admin/password_change",
1727            "/admin/forgot-password",
1728            "/admin/reauth",
1729            "/admin/must-change-password/", // trailing slash → not exact
1730        ] {
1731            assert!(
1732                !super::is_must_change_whitelisted_path(path),
1733                "expected reject for {path:?}"
1734            );
1735        }
1736    }
1737
1738    #[test]
1739    fn whitelist_rejects_paths_outside_admin_surface() {
1740        for path in ["/", "/login", "/static/admin.css", "/api"] {
1741            assert!(
1742                !super::is_must_change_whitelisted_path(path),
1743                "expected reject for {path:?}"
1744            );
1745        }
1746    }
1747}