Skip to main content

autumn_admin_plugin/
templates.rs

1//! Maud templates for the admin panel.
2//!
3//! All templates are server-rendered with HTMX for interactivity.
4//! The design mirrors the actuator UI: system-ui font, Tailwind-ish
5//! color palette, clean cards with subtle shadows.
6
7use autumn_web::flash::{FLASH_CSS, FlashMessage, flash_messages};
8use autumn_web::job::{
9    JobAdminPage, JobAdminRecord, JobAdminSnapshot, JobAdminStatus, JobScheduleSummary,
10};
11use autumn_web::pagination::Page;
12use autumn_web::runtime_config::{ConfigChangeRecord, ConfigEntry};
13use autumn_web::ui::pagination::{PagerOptions, pagination_nav};
14use autumn_web::widgets::{
15    CardConfig, ConfirmActionConfig, ModalConfig, NavBarConfig, NavBarLayout, NavItem, card,
16    confirm_action, modal, modal_close_button, nav_bar, stat_card,
17};
18use http::Method;
19use maud::{DOCTYPE, Markup, PreEscaped, html};
20use serde_json::Value;
21
22use crate::registry::{AdminRegistry, JOBS_NAV_SLUG, RUNTIME_CONFIG_NAV_SLUG};
23use crate::traits::{
24    AdminAction, AdminField, AdminFieldKind, AdminHistoryPage, AdminImportReport, CsvImportMode,
25    ListResult, SortDirection, record_id,
26};
27
28const TOKENS_CSS: &str = include_str!("tokens.css");
29
30// ── CSS ─────────────────────────────────────────────────────────────
31
32/// Admin-specific styles that build on the plugin's shared tokens
33/// ([`TOKENS_CSS`]) and the framework's shared flash styles
34/// ([`autumn_web::flash::FLASH_CSS`]).
35const ADMIN_CSS: &str = "
36    /* Skip-to-content link: visually hidden at rest, revealed on keyboard focus. */
37    .admin-skip-link {
38        position: absolute;
39        top: -9999px;
40        left: 0;
41        z-index: 9999;
42        padding: 0.5rem 1rem;
43        background: var(--primary);
44        color: #fff;
45        border-radius: 0 0 0.25rem 0.25rem;
46        font-size: 0.875rem;
47        text-decoration: none;
48    }
49    .admin-skip-link:focus {
50        top: 0;
51        outline: 3px solid var(--primary);
52        outline-offset: 2px;
53    }
54    * { box-sizing: border-box; margin: 0; padding: 0; }
55    body {
56        font-family: var(--font-family);
57        background: var(--bg);
58        color: var(--text);
59        line-height: 1.5;
60    }
61    a { color: var(--primary); text-decoration: none; }
62    a:hover { text-decoration: underline; }
63
64    /* Layout */
65    .admin-layout { display: flex; min-height: 100vh; }
66    .admin-sidebar {
67        width: 240px;
68        background: var(--surface);
69        border-right: 1px solid var(--border);
70        padding: 1.5rem 0;
71        position: fixed;
72        top: 0;
73        left: 0;
74        bottom: 0;
75        overflow-y: auto;
76    }
77    .admin-main {
78        margin-left: 240px;
79        flex: 1;
80        padding: 2rem;
81        min-width: 0;
82    }
83    .admin-sidebar .autumn-nav__brand {
84        display: block;
85        font-size: 1.125rem;
86        font-weight: 700;
87        padding: 0 1.5rem 1rem;
88        border-bottom: 1px solid var(--border);
89        margin-bottom: 1rem;
90        color: var(--text);
91    }
92    .admin-sidebar .autumn-nav__items { list-style: none; }
93    .admin-sidebar .autumn-nav__item a {
94        display: block;
95        padding: 0.5rem 1.5rem;
96        color: var(--text-muted);
97        font-size: 0.875rem;
98        font-weight: 500;
99        border-left: 3px solid transparent;
100        transition: all 0.15s;
101    }
102    .admin-sidebar .autumn-nav__item a:hover {
103        background: var(--bg);
104        color: var(--text);
105        text-decoration: none;
106    }
107    .admin-sidebar .autumn-nav__item a.active {
108        background: var(--primary-light);
109        color: var(--primary);
110        border-left-color: var(--primary);
111    }
112    .admin-sidebar .autumn-nav__section {
113        font-size: 0.7rem;
114        text-transform: uppercase;
115        letter-spacing: 0.05em;
116        color: var(--text-muted);
117        padding: 1rem 1.5rem 0.375rem;
118        font-weight: 600;
119    }
120    /* The sidebar hides itself entirely below 768px (see the .admin-sidebar
121       rule in the Responsive section) instead of collapsing behind nav_bar's
122       own hamburger toggle, so the toggle stays hidden at every width. */
123    .admin-sidebar .autumn-nav__toggle {
124        display: none;
125    }
126
127    /* Cards */
128    .autumn-card {
129        background: var(--surface);
130        border-radius: var(--radius);
131        box-shadow: var(--shadow);
132        margin-bottom: 1.5rem;
133    }
134    .autumn-card__header {
135        display: flex;
136        justify-content: space-between;
137        align-items: center;
138        padding: 1rem 1.5rem 0.75rem;
139        border-bottom: 1px solid var(--border);
140    }
141    .header-actions form { display: inline; }
142    .autumn-card__title {
143        font-size: 1.125rem;
144        font-weight: 600;
145        margin: 0;
146    }
147    .autumn-card__body {
148        padding: 1.5rem;
149    }
150
151    /* Buttons */
152    .btn {
153        display: inline-flex;
154        align-items: center;
155        gap: 0.375rem;
156        padding: 0.5rem 1rem;
157        border-radius: 0.375rem;
158        font-size: 0.875rem;
159        font-weight: 500;
160        border: 1px solid var(--border);
161        background: var(--surface);
162        color: var(--text);
163        cursor: pointer;
164        transition: all 0.15s;
165    }
166    .btn:hover { background: var(--bg); text-decoration: none; }
167    .btn-primary {
168        background: var(--primary);
169        color: white;
170        border-color: var(--primary);
171    }
172    .btn-primary:hover { background: var(--primary-hover); }
173    .btn-danger {
174        background: var(--danger);
175        color: white;
176        border-color: var(--danger);
177    }
178    .btn-danger:hover { background: var(--danger-hover); }
179    .btn-sm { padding: 0.25rem 0.625rem; font-size: 0.8125rem; }
180
181    /* Tables */
182    .table-wrap { overflow-x: auto; }
183    table {
184        width: 100%;
185        border-collapse: collapse;
186        font-size: 0.875rem;
187    }
188    th {
189        text-align: left;
190        padding: 0.75rem;
191        font-weight: 600;
192        color: var(--text-muted);
193        font-size: 0.75rem;
194        text-transform: uppercase;
195        letter-spacing: 0.05em;
196        border-bottom: 2px solid var(--border);
197        white-space: nowrap;
198        user-select: none;
199    }
200    th a { cursor: pointer; }
201    th a:hover { color: var(--text); }
202    th .sort-icon { font-size: 0.625rem; margin-left: 0.25rem; }
203    td {
204        padding: 0.75rem;
205        border-bottom: 1px solid var(--border);
206        max-width: 300px;
207        overflow: hidden;
208        text-overflow: ellipsis;
209        white-space: nowrap;
210    }
211    tr:hover td { background: var(--bg); }
212    .checkbox-cell { width: 40px; text-align: center; }
213
214    /* Forms */
215    .form-group { margin-bottom: 1rem; }
216    .form-label {
217        display: block;
218        font-size: 0.875rem;
219        font-weight: 500;
220        margin-bottom: 0.375rem;
221        color: var(--text);
222    }
223    .form-label .required { color: var(--danger); margin-left: 0.125rem; }
224    .form-input {
225        width: 100%;
226        padding: 0.5rem 0.75rem;
227        border: 1px solid var(--border);
228        border-radius: 0.375rem;
229        font-size: 0.875rem;
230        line-height: 1.5;
231        background: var(--surface);
232        color: var(--text);
233        transition: border-color 0.15s;
234    }
235    .form-input:focus {
236        outline: 2px solid var(--primary);
237        outline-offset: 2px;
238        border-color: var(--primary);
239        box-shadow: 0 0 0 3px var(--primary-light);
240    }
241    textarea.form-input { min-height: 100px; resize: vertical; }
242    select.form-input { appearance: auto; }
243
244    /* Action bar (bulk actions) */
245    .action-bar {
246        display: flex;
247        gap: 0.5rem;
248        align-items: center;
249        margin-top: 0.75rem;
250        padding-top: 0.75rem;
251        border-top: 1px solid var(--border);
252        font-size: 0.875rem;
253        color: var(--text-muted);
254    }
255
256    /* Search bar */
257    .search-bar {
258        display: flex;
259        gap: 0.75rem;
260        margin-bottom: 1rem;
261        align-items: center;
262    }
263    .search-bar input {
264        flex: 1;
265        padding: 0.5rem 0.75rem;
266        border: 1px solid var(--border);
267        border-radius: 0.375rem;
268        font-size: 0.875rem;
269    }
270    .search-bar input:focus {
271        outline: 2px solid var(--primary);
272        outline-offset: 2px;
273        border-color: var(--primary);
274        box-shadow: 0 0 0 3px var(--primary-light);
275    }
276
277    /* Pagination */
278    .pagination {
279        display: flex;
280        justify-content: space-between;
281        align-items: center;
282        margin-top: 1rem;
283        font-size: 0.875rem;
284        color: var(--text-muted);
285    }
286    .autumn-pager {
287        display: flex;
288        gap: 0.25rem;
289    }
290    .autumn-pager a, .autumn-pager span {
291        padding: 0.375rem 0.75rem;
292        border: 1px solid var(--border);
293        border-radius: 0.375rem;
294        font-size: 0.8125rem;
295        color: var(--text);
296    }
297    .autumn-pager a:hover { background: var(--bg); text-decoration: none; }
298    .autumn-pager .autumn-pager__current {
299        background: var(--primary);
300        color: white;
301        border-color: var(--primary);
302    }
303    .autumn-pager .autumn-pager__ellipsis { border: none; color: var(--text-muted); }
304    .autumn-pager .autumn-pager__disabled { color: var(--text-muted); opacity: 0.5; }
305
306    /* Dashboard stats */
307    .stats-grid {
308        display: grid;
309        grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
310        gap: 1rem;
311        margin-bottom: 1.5rem;
312    }
313    .autumn-stat-card {
314        background: var(--surface);
315        border-radius: var(--radius);
316        box-shadow: var(--shadow);
317        padding: 1.25rem;
318    }
319    .autumn-stat-card__label { font-size: 0.8125rem; color: var(--text-muted); font-weight: 500; }
320    .autumn-stat-card__value { font-size: 1.75rem; font-weight: 700; margin-top: 0.25rem; }
321    .autumn-stat-card__link { font-size: 0.8125rem; margin-top: 0.375rem; }
322    .jobs-counter-grid {
323        display: grid;
324        grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
325        gap: 0.75rem;
326        margin-bottom: 1rem;
327    }
328    .jobs-counter {
329        border: 1px solid var(--border);
330        border-radius: var(--radius);
331        padding: 0.875rem;
332        background: var(--bg);
333    }
334    .jobs-counter strong {
335        display: block;
336        font-size: 1.35rem;
337        line-height: 1.1;
338        margin-top: 0.2rem;
339    }
340    .job-error summary {
341        cursor: pointer;
342        color: var(--danger);
343    }
344    .job-error pre {
345        margin-top: 0.5rem;
346        white-space: pre-wrap;
347        word-break: break-word;
348        background: var(--danger-light);
349        border-radius: 0.375rem;
350        padding: 0.5rem;
351        max-width: 32rem;
352    }
353    .job-blocked {
354        color: var(--warning-text);
355        font-weight: 600;
356    }
357    .job-actions {
358        display: flex;
359        gap: 0.375rem;
360        flex-wrap: wrap;
361    }
362    .job-actions form { display: inline; }
363
364    /* Breadcrumbs */
365    .breadcrumbs {
366        font-size: 0.875rem;
367        color: var(--text-muted);
368        margin-bottom: 1rem;
369    }
370    .breadcrumbs a { color: var(--text-muted); }
371    .breadcrumbs a:hover { color: var(--primary); }
372    .breadcrumbs .sep { margin: 0 0.5rem; }
373
374    /* Detail view */
375    .detail-grid {
376        display: grid;
377        grid-template-columns: 160px 1fr;
378        gap: 0;
379    }
380    .detail-label {
381        padding: 0.75rem;
382        font-weight: 500;
383        color: var(--text-muted);
384        font-size: 0.875rem;
385        border-bottom: 1px solid var(--border);
386        background: var(--bg);
387    }
388    .detail-value {
389        padding: 0.75rem;
390        font-size: 0.875rem;
391        border-bottom: 1px solid var(--border);
392        word-break: break-word;
393    }
394
395    /* Responsive */
396    @media (max-width: 768px) {
397        .admin-sidebar { display: none; }
398        .admin-main { margin-left: 0; }
399        .stats-grid { grid-template-columns: 1fr 1fr; }
400    }
401    ";
402
403// ── Impersonation banner ────────────────────────────────────────────
404
405/// Styles for the impersonation banner ([`impersonation_banner`]).
406///
407/// The admin layout already includes these. An application that embeds the
408/// banner in its **own** layout — the surface an operator actually sees while
409/// impersonating a non-admin user — should drop this into its stylesheet or a
410/// `<style>` block. Deliberately class-based rather than inline `style`
411/// attributes, so it survives a nonce-based `style-src` CSP.
412pub const IMPERSONATION_BANNER_CSS: &str = "
413    .autumn-impersonation-banner {
414        position: sticky;
415        top: 0;
416        z-index: 1000;
417        display: flex;
418        flex-wrap: wrap;
419        gap: 0.75rem;
420        align-items: center;
421        justify-content: space-between;
422        padding: 0.6rem 1rem;
423        background: #b45309;
424        color: #fff;
425        font: 500 0.9rem/1.4 system-ui, -apple-system, sans-serif;
426        box-shadow: 0 1px 3px rgb(0 0 0 / 25%);
427    }
428    .autumn-impersonation-banner__text { margin: 0; }
429    .autumn-impersonation-banner__who { font-weight: 700; }
430    .autumn-impersonation-banner__form { margin: 0; }
431    .autumn-impersonation-banner__stop {
432        padding: 0.35rem 0.85rem;
433        border: 1px solid rgb(255 255 255 / 60%);
434        border-radius: 4px;
435        background: transparent;
436        color: inherit;
437        font: inherit;
438        cursor: pointer;
439    }
440    .autumn-impersonation-banner__stop:hover { background: rgb(255 255 255 / 15%); }
441    .autumn-impersonation-banner__stop:focus-visible {
442        outline: 2px solid #fff;
443        outline-offset: 2px;
444    }
445";
446
447/// Everything the impersonation banner needs to render.
448///
449/// Built from an [`ImpersonationState`](autumn_web::auth::impersonation::ImpersonationState)
450/// plus the request's CSRF token; see
451/// [`impersonation_banner_for`](crate::impersonation_banner_for) for the
452/// one-call version that reads both from the request.
453#[derive(Debug, Clone)]
454#[non_exhaustive]
455pub struct ImpersonationBanner {
456    /// The user the session is currently acting as.
457    pub effective_user_id: String,
458    /// The real operator behind the session.
459    pub impersonator_id: String,
460    /// Prefix the admin plugin is mounted at; the revert form posts to
461    /// `{admin_prefix}/impersonate/stop`.
462    pub admin_prefix: String,
463    /// CSRF token for that form. Empty when no `CsrfLayer` is installed, in
464    /// which case the hidden field is omitted entirely.
465    pub csrf_token: String,
466    /// Configured CSRF form-field name (defaults to `_csrf` when empty).
467    pub csrf_form_field: String,
468    /// Path the browser returns to after reverting. Empty means "the admin
469    /// panel"; the revert route re-validates it as a same-origin relative path
470    /// either way, so it can never become an open redirect.
471    pub return_to: String,
472}
473
474impl ImpersonationBanner {
475    /// Build the banner view-model for an active impersonation.
476    #[must_use]
477    pub fn new(
478        state: &autumn_web::auth::impersonation::ImpersonationState,
479        admin_prefix: &str,
480        csrf_token: &str,
481        csrf_form_field: &str,
482    ) -> Self {
483        Self {
484            effective_user_id: state.effective_user_id.clone(),
485            impersonator_id: state.impersonator_id.clone(),
486            admin_prefix: admin_prefix.to_owned(),
487            csrf_token: csrf_token.to_owned(),
488            csrf_form_field: csrf_form_field.to_owned(),
489            return_to: String::new(),
490        }
491    }
492
493    /// Return the operator to `path` after reverting, instead of to the admin
494    /// panel. Worth setting when the banner is embedded in the application's
495    /// own layout. Re-validated server-side on the revert route.
496    #[must_use]
497    pub fn returning_to(mut self, path: impl Into<String>) -> Self {
498        self.return_to = path.into();
499        self
500    }
501
502    /// The CSRF field name to render, defaulting to Autumn's `_csrf`.
503    fn csrf_field(&self) -> &str {
504        if self.csrf_form_field.is_empty() {
505            "_csrf"
506        } else {
507            &self.csrf_form_field
508        }
509    }
510}
511
512/// Render the persistent "Viewing as … — Stop impersonating" banner.
513///
514/// The revert is a single `POST` to the plugin's stop route, which is mounted
515/// **outside** the admin role gate — so an operator impersonating a user
516/// without the admin role can always get back to their own session.
517///
518/// Embed it at the top of `<body>` in your application's layout:
519///
520/// ```rust,ignore
521/// let banner = autumn_admin_plugin::impersonation_banner_for(
522///     &state, &session, "/admin", csrf.token(), csrf.form_field(),
523/// ).await;
524/// html! {
525///     body {
526///         @if let Some(banner) = banner { (banner) }
527///         main { (content) }
528///     }
529/// }
530/// ```
531#[must_use]
532pub fn impersonation_banner(banner: &ImpersonationBanner) -> Markup {
533    let stop_action = format!(
534        "{}/impersonate/stop",
535        banner.admin_prefix.trim_end_matches('/')
536    );
537    html! {
538        div class="autumn-impersonation-banner" role="status" aria-live="polite" {
539            p class="autumn-impersonation-banner__text" {
540                "Viewing as "
541                span class="autumn-impersonation-banner__who" { (banner.effective_user_id) }
542                " — impersonated by "
543                span class="autumn-impersonation-banner__who" { (banner.impersonator_id) }
544            }
545            form class="autumn-impersonation-banner__form" method="post" action=(stop_action) {
546                @if !banner.csrf_token.is_empty() {
547                    input type="hidden" name=(banner.csrf_field()) value=(banner.csrf_token);
548                }
549                @if !banner.return_to.is_empty() {
550                    input type="hidden" name="return_to" value=(banner.return_to);
551                }
552                button type="submit" class="autumn-impersonation-banner__stop" {
553                    "Stop impersonating"
554                }
555            }
556        }
557    }
558}
559
560// ── Layout ──────────────────────────────────────────────────────────
561
562/// Render the full admin page layout with sidebar navigation.
563#[allow(clippy::too_many_arguments)]
564pub fn admin_layout(
565    registry: &AdminRegistry,
566    active_slug: Option<&str>,
567    title: &str,
568    prefix: &str,
569    actuator_prefix: &str,
570    csrf_token: &str,
571    csrf_token_header: &str,
572    messages: &[FlashMessage],
573    show_config: bool,
574    impersonation: Option<&ImpersonationBanner>,
575    content: &Markup,
576) -> Markup {
577    // Each nav item's href is computed once here and reused both to
578    // synthesize current_path (so nav_link's own path-comparison logic
579    // decides which sidebar item is active) and as the anchor's href below —
580    // so the "/jobs" and "/config" route suffixes each appear as a literal
581    // exactly once.
582    let jobs_href = format!("{prefix}/jobs");
583    let config_href = format!("{prefix}/config");
584    let current_path = match active_slug {
585        None => prefix.to_owned(),
586        Some(JOBS_NAV_SLUG) => jobs_href.clone(),
587        Some(RUNTIME_CONFIG_NAV_SLUG) => config_href.clone(),
588        Some(slug) => format!("{prefix}/{slug}"),
589    };
590
591    let mut nav_items = vec![NavItem::link(prefix, "Dashboard")];
592    if registry.model_count() > 0 {
593        nav_items.push(NavItem::section("Models"));
594        nav_items.extend(registry.iter().map(|(slug, model)| {
595            NavItem::link(format!("{prefix}/{slug}"), model.display_name_plural())
596        }));
597    }
598    nav_items.push(NavItem::section("System"));
599    nav_items.push(NavItem::link(jobs_href, "Jobs"));
600    if show_config {
601        nav_items.push(NavItem::link(config_href, "Runtime Config"));
602    }
603    nav_items.push(NavItem::plain_link(
604        format!("{actuator_prefix}/ui"),
605        "Actuator",
606    ));
607    let sidebar_nav = NavBarConfig::new()
608        .brand_html(html! { "🍂 Autumn Admin" }, None)
609        .items(nav_items)
610        .aria_label("Admin navigation")
611        .layout(NavBarLayout::Sidebar)
612        .class("admin-sidebar");
613
614    html! {
615        (DOCTYPE)
616        html lang="en" {
617            head {
618                meta charset="utf-8";
619                meta name="viewport" content="width=device-width, initial-scale=1";
620                // CSRF token for HTMX requests (hx-delete, hx-post). The
621                // companion `js/autumn-htmx-csrf.js` script reads this meta tag
622                // and attaches the configured header to outgoing htmx requests.
623                // The admin JS multipart handler uses data-header to send the
624                // right header name when security.csrf.token_header is customised.
625                meta name="csrf-token" content=(csrf_token) data-header=(csrf_token_header);
626                title { (title) " — Autumn Admin" }
627                // `asset_url` hands out the content-hashed URLs of the
628                // framework's scripts (or an app's pinned htmx, when vendored).
629                script src=(autumn_web::assets::asset_url("js/htmx.min.js")) {}
630                script src=(autumn_web::assets::asset_url("js/autumn-htmx-csrf.js")) {}
631                // Reveals/wires up nav_bar's hamburger toggle and any future
632                // dropdown menu; the sidebar itself stays fully visible/hidden
633                // via the .admin-sidebar media-query rule below, not the
634                // toggle, so its own toggle button is kept CSS-hidden always.
635                script src=(autumn_web::assets::asset_url("js/autumn-widgets.js")) defer {}
636                // External so it runs under the default CSP `script-src 'self'`.
637                (crate::routes::ASSETS.script_tag("admin.js"))
638                style {
639                    (PreEscaped(TOKENS_CSS))
640                    (PreEscaped(FLASH_CSS))
641                    (PreEscaped(ADMIN_CSS))
642                    (PreEscaped(IMPERSONATION_BANNER_CSS))
643                }
644            }
645            body {
646                // Skip-to-content link — first focusable element for keyboard users.
647                a href="#admin-main" class="admin-skip-link" { "Skip to main content" }
648                // Persistent impersonation banner (#1394): rendered above the
649                // whole layout so it is on every admin page the operator loads.
650                @if let Some(banner) = impersonation {
651                    (impersonation_banner(banner))
652                }
653                div class="admin-layout" {
654                    // Sidebar navigation landmark
655                    header role="banner" {
656                        (nav_bar(&current_path, &sidebar_nav))
657                    }
658                    // Main content landmark
659                    main id="admin-main" class="admin-main" {
660                        (flash_messages(messages))
661                        (content)
662                    }
663                }
664            }
665        }
666    }
667}
668
669// ── Jobs dashboard ──────────────────────────────────────────────────
670
671fn csrf_hidden_input(csrf_token: &str, csrf_form_field: &str) -> Markup {
672    html! {
673        input type="hidden" name=(csrf_form_field) value=(csrf_token);
674    }
675}
676
677/// Render the built-in jobs admin dashboard.
678#[allow(clippy::too_many_arguments)]
679pub fn jobs_page(
680    registry: &AdminRegistry,
681    snapshot: &JobAdminSnapshot,
682    messages: &[FlashMessage],
683    csrf_token: &str,
684    csrf_form_field: &str,
685    csrf_token_header: &str,
686    prefix: &str,
687    actuator_prefix: &str,
688    show_config: bool,
689    impersonation: Option<&ImpersonationBanner>,
690) -> Markup {
691    let content = html! {
692        div class="breadcrumbs" {
693            a href=(prefix) { "Admin" }
694            span class="sep" { "›" }
695            span { "Jobs" }
696        }
697
698        h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 1rem;" {
699            "Jobs"
700        }
701
702        (jobs_counters(snapshot, prefix))
703
704        (job_list_card(
705            "Enqueued",
706            "Work waiting for a worker, including jobs waiting on a concurrency slot.",
707            &snapshot.enqueued,
708            "enqueued_page",
709            csrf_token,
710            csrf_form_field,
711            prefix,
712        ))
713        (job_list_card(
714            "Scheduled",
715            "Delayed one-shot jobs waiting for their due time. Cancel before they run.",
716            &snapshot.scheduled,
717            "scheduled_page",
718            csrf_token,
719            csrf_form_field,
720            prefix,
721        ))
722        (job_list_card(
723            "Running",
724            "Work currently executing in this runtime.",
725            &snapshot.running,
726            "running_page",
727            csrf_token,
728            csrf_form_field,
729            prefix,
730        ))
731        (job_list_card(
732            "Completed (last 24h)",
733            "Recently completed work retained by the bounded dashboard history.",
734            &snapshot.completed,
735            "completed_page",
736            csrf_token,
737            csrf_form_field,
738            prefix,
739        ))
740        (job_list_card(
741            "Failed (last 7d)",
742            "Terminal failures available for retry or discard.",
743            &snapshot.failed,
744            "failed_page",
745            csrf_token,
746            csrf_form_field,
747            prefix,
748        ))
749        (job_schedules_card(&snapshot.schedules))
750
751        p style="font-size: 0.8125rem; color: var(--text-muted); margin-top: 1rem;" {
752            "Default backend history is bounded to " (snapshot.bounded_history_limit)
753            " lifecycle entries; counter refreshes use bounded in-memory reads."
754        }
755    };
756
757    admin_layout(
758        registry,
759        Some(JOBS_NAV_SLUG),
760        "Jobs",
761        prefix,
762        actuator_prefix,
763        csrf_token,
764        csrf_token_header,
765        messages,
766        show_config,
767        impersonation,
768        &content,
769    )
770}
771
772/// Render the HTMX-refreshable job counter fragment.
773pub fn jobs_counters(snapshot: &JobAdminSnapshot, prefix: &str) -> Markup {
774    html! {
775        div id="jobs-counters"
776            class="jobs-counter-grid"
777            hx-get={ (prefix) "/jobs/counters" }
778            hx-trigger="load, every 2s"
779            hx-swap="outerHTML" {
780            (job_counter("Enqueued", snapshot.enqueued.total))
781            (job_counter("Scheduled", snapshot.scheduled.total))
782            (job_counter("Running", snapshot.running.total))
783            (job_counter("Completed 24h", snapshot.completed.total))
784            (job_counter("Failed 7d", snapshot.failed.total))
785        }
786    }
787}
788
789fn job_counter(label: &str, value: u64) -> Markup {
790    html! {
791        div class="jobs-counter" {
792            span class="autumn-stat-card__label" { (label) }
793            strong { (value) }
794        }
795    }
796}
797
798fn job_list_card(
799    title: &str,
800    description: &str,
801    page: &JobAdminPage,
802    page_param: &str,
803    csrf_token: &str,
804    csrf_form_field: &str,
805    prefix: &str,
806) -> Markup {
807    let header_action = html! {
808        span style="font-size: 0.875rem; color: var(--text-muted);" {
809            (page.total) " total"
810        }
811    };
812    let title_markup = html! {
813        (title)
814        span style="display: block; font-size: 0.8125rem; color: var(--text-muted); margin-top: 0.25rem; font-weight: 400;" {
815            (description)
816        }
817    };
818    let body = html! {
819        div class="table-wrap" {
820            table {
821                thead {
822                    tr {
823                        th { "Job" }
824                        th { "Enqueued At" }
825                        th { "Started At" }
826                        th { "Finished At" }
827                        th { "Attempts" }
828                        th { "Principal" }
829                        th { "Correlation" }
830                        th { "Last Error" }
831                        th { "Actions" }
832                    }
833                }
834                tbody {
835                    @if page.records.is_empty() {
836                        tr {
837                            td colspan="9" style="text-align: center; padding: 1.5rem; color: var(--text-muted);" {
838                                "No jobs."
839                            }
840                        }
841                    }
842                    @for record in &page.records {
843                        (job_row(record, csrf_token, csrf_form_field, prefix))
844                    }
845                }
846            }
847        }
848        (jobs_pagination(page, page_param, prefix))
849    };
850    card(
851        &body,
852        &CardConfig::new()
853            .title_html(title_markup)
854            .header_action(header_action),
855    )
856}
857
858fn job_row(
859    record: &JobAdminRecord,
860    csrf_token: &str,
861    csrf_form_field: &str,
862    prefix: &str,
863) -> Markup {
864    html! {
865        tr {
866            td {
867                strong { (record.name) }
868                div style="font-size: 0.75rem; color: var(--text-muted);" {
869                    (record.status.label())
870                    @if record.blocked_on_concurrency {
871                        " · " span class="job-blocked" { "waiting on a concurrency slot" }
872                    }
873                    " · queue " (record.queue) " · " (record.id)
874                    @if let Some(due) = record.scheduled_for.as_deref() {
875                        " · due " (due)
876                    }
877                }
878            }
879            td { (optional_text(record.enqueued_at.as_deref())) }
880            td { (optional_text(record.started_at.as_deref())) }
881            td { (optional_text(record.finished_at.as_deref())) }
882            td { (record.attempt) "/" (record.max_attempts) }
883            td { (optional_text(record.principal_id.as_deref())) }
884            td { (optional_text(record.correlation_id.as_deref())) }
885            td { (job_error(record)) }
886            td { (job_actions(record, csrf_token, csrf_form_field, prefix)) }
887        }
888    }
889}
890
891fn job_error(record: &JobAdminRecord) -> Markup {
892    let Some(error) = record.last_error.as_deref() else {
893        return html! { span style="color: var(--text-muted);" { "—" } };
894    };
895    if record.status == JobAdminStatus::Failed {
896        html! {
897            details class="job-error" {
898                summary { (truncate_display(error, 80)) }
899                pre { (error) }
900            }
901        }
902    } else {
903        html! { (truncate_display(error, 80)) }
904    }
905}
906
907fn job_actions(
908    record: &JobAdminRecord,
909    csrf_token: &str,
910    csrf_form_field: &str,
911    prefix: &str,
912) -> Markup {
913    html! {
914        div class="job-actions" {
915            @if record.status == JobAdminStatus::Failed {
916                (job_action_form(prefix, &record.id, "retry", "Retry", "btn btn-sm btn-primary", csrf_token, csrf_form_field))
917                (job_action_form(prefix, &record.id, "discard", "Discard", "btn btn-sm btn-danger", csrf_token, csrf_form_field))
918            } @else if record.status == JobAdminStatus::Enqueued || record.status == JobAdminStatus::Scheduled {
919                (job_action_form(prefix, &record.id, "cancel", "Cancel", "btn btn-sm btn-danger", csrf_token, csrf_form_field))
920            } @else {
921                span style="color: var(--text-muted);" { "—" }
922            }
923        }
924    }
925}
926
927fn job_action_form(
928    prefix: &str,
929    id: &str,
930    action: &str,
931    label: &str,
932    class_name: &str,
933    csrf_token: &str,
934    csrf_form_field: &str,
935) -> Markup {
936    html! {
937        form method="post" action={ (prefix) "/jobs/" (id) "/" (action) } {
938            (csrf_hidden_input(csrf_token, csrf_form_field))
939            button type="submit" class=(class_name) {
940                (label)
941            }
942        }
943    }
944}
945
946fn jobs_pagination(page: &JobAdminPage, page_param: &str, prefix: &str) -> Markup {
947    if page.total_pages() <= 1 {
948        return html! {};
949    }
950    let meta = page_meta(page.page, page.per_page, page.total, page.total_pages());
951    let base = format!("{prefix}/jobs");
952    let opts = PagerOptions::new(&base).page_param(page_param).window(1);
953    html! {
954        div class="pagination" {
955            div {
956                "Page " (page.page) " of " (page.total_pages())
957            }
958            (pagination_nav(&meta, &opts))
959        }
960    }
961}
962
963fn job_schedules_card(schedules: &[JobScheduleSummary]) -> Markup {
964    let body = html! {
965        div class="table-wrap" {
966            table {
967                thead {
968                    tr {
969                        th { "Name" }
970                        th { "Schedule" }
971                        th { "Next Run At" }
972                        th { "Last Run Status" }
973                    }
974                }
975                tbody {
976                    @if schedules.is_empty() {
977                        tr {
978                            td colspan="4" style="text-align: center; padding: 1.5rem; color: var(--text-muted);" {
979                                "No scheduled tasks registered."
980                            }
981                        }
982                    }
983                    @for schedule in schedules {
984                        tr {
985                            td { (schedule.name) }
986                            td { (schedule.schedule) }
987                            td { (optional_text(schedule.next_run_at.as_deref())) }
988                            td { (optional_text(schedule.last_run_status.as_deref())) }
989                        }
990                    }
991                }
992            }
993        }
994    };
995    card(&body, &CardConfig::new().title("Recurring Schedules"))
996}
997
998fn optional_text(value: Option<&str>) -> Markup {
999    value.filter(|value| !value.is_empty()).map_or_else(
1000        || html! { span style="color: var(--text-muted);" { "—" } },
1001        |value| html! { (value) },
1002    )
1003}
1004
1005// ── Dashboard ───────────────────────────────────────────────────────
1006
1007/// Render the admin dashboard with model counts.
1008#[allow(clippy::too_many_arguments)]
1009pub fn dashboard_page(
1010    registry: &AdminRegistry,
1011    model_counts: &[(&str, &str, u64)], // (slug, display_name_plural, count)
1012    messages: &[FlashMessage],
1013    csrf_token: &str,
1014    csrf_token_header: &str,
1015    prefix: &str,
1016    actuator_prefix: &str,
1017    show_config: bool,
1018    impersonation: Option<&ImpersonationBanner>,
1019) -> Markup {
1020    let content = html! {
1021        h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem;" {
1022            "Dashboard"
1023        }
1024
1025        div class="stats-grid" {
1026            @for (slug, name, count) in model_counts {
1027                (stat_card(name, &count.to_string(), Some((&format!("{prefix}/{slug}"), "View all →"))))
1028            }
1029        }
1030
1031        // Actuator summary (loaded via HTMX)
1032        ({
1033            let action = html! {
1034                a href={ (actuator_prefix) "/ui" } class="btn btn-sm" { "Full Dashboard →" }
1035            };
1036            let body = html! {
1037                div hx-get={ (actuator_prefix) "/ui/metrics" } hx-trigger="load, every 5s" {
1038                    "Loading metrics…"
1039                }
1040            };
1041            card(&body, &CardConfig::new().title("System Health").header_action(action))
1042        })
1043    };
1044    admin_layout(
1045        registry,
1046        None,
1047        "Dashboard",
1048        prefix,
1049        actuator_prefix,
1050        csrf_token,
1051        csrf_token_header,
1052        messages,
1053        show_config,
1054        impersonation,
1055        &content,
1056    )
1057}
1058
1059// ── Model list view ─────────────────────────────────────────────────
1060
1061/// Render the list view for a model (table + search + pagination).
1062#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1063pub fn model_list_page(
1064    registry: &AdminRegistry,
1065    model_slug: &str,
1066    model_name_plural: &str,
1067    fields: &[AdminField],
1068    actions: &[AdminAction],
1069    result: &ListResult,
1070    search_query: &str,
1071    sort_by: Option<&str>,
1072    sort_dir: SortDirection,
1073    // Active filters (already validated by the handler). Carried into
1074    // every generated sort/pagination URL so navigation doesn't silently
1075    // revert to unfiltered results.
1076    filters: &[(String, String)],
1077    messages: &[FlashMessage],
1078    csrf_token: &str,
1079    csrf_form_field: &str,
1080    csrf_token_header: &str,
1081    prefix: &str,
1082    actuator_prefix: &str,
1083    show_config: bool,
1084    supports_csv_export: bool,
1085    supports_csv_import: bool,
1086    impersonation: Option<&ImpersonationBanner>,
1087) -> Markup {
1088    // Password fields are documented as write-only — never surface their
1089    // values (raw or hashed) in the index view. Hidden fields are
1090    // documented as "shown in detail, not editable" — so they should
1091    // also stay out of the list view, regardless of `list_display`.
1092    let list_fields: Vec<_> = fields
1093        .iter()
1094        .filter(|f| {
1095            f.list_display && !matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden)
1096        })
1097        .collect();
1098    let search_enc = url_encode(search_query);
1099    // Pre-encode active filters into a `&filter.<k>=<v>` suffix so
1100    // sort/pagination links carry filter state forward without rebuilding it.
1101    let filters_enc = encode_filter_suffix(filters);
1102    // Export URL preserves the current search/sort/filter state so "Download CSV"
1103    // exports exactly the rows shown on the page, not the whole table.
1104    let export_csv_url = {
1105        let mut params: Vec<String> = Vec::new();
1106        if !search_enc.is_empty() {
1107            params.push(format!("q={search_enc}"));
1108        }
1109        if let Some(sort) = sort_by {
1110            params.push(format!("sort={}", url_encode(sort)));
1111            params.push(format!("dir={}", sort_dir.as_str()));
1112        }
1113        for (k, v) in filters {
1114            params.push(format!("filter.{}={}", url_encode(k), url_encode(v)));
1115        }
1116        if params.is_empty() {
1117            format!("{prefix}/{model_slug}/export.csv")
1118        } else {
1119            format!("{prefix}/{model_slug}/export.csv?{}", params.join("&"))
1120        }
1121    };
1122
1123    let content = html! {
1124        // Breadcrumbs
1125        div class="breadcrumbs" {
1126            a href=(prefix) { "Admin" }
1127            span class="sep" { "›" }
1128            span { (model_name_plural) }
1129        }
1130
1131        ({
1132            let title = html! {
1133                (model_name_plural)
1134                span style="font-weight: 400; color: var(--text-muted); margin-left: 0.5rem;" {
1135                    "(" (result.total) ")"
1136                }
1137            };
1138            let header_action = html! {
1139                div style="display: flex; gap: 0.5rem; align-items: center;" {
1140                    @if supports_csv_export {
1141                        a href=(export_csv_url) class="btn btn-sm"
1142                            title="Download all matching records as CSV" {
1143                            "⬇ Download CSV"
1144                        }
1145                    }
1146                    @if supports_csv_import {
1147                        a href={ (prefix) "/" (model_slug) "/import" } class="btn btn-sm"
1148                            title="Upload a CSV file to import records" {
1149                            "⬆ Import CSV"
1150                        }
1151                    }
1152                    a href={ (prefix) "/" (model_slug) "/new" } class="btn btn-primary" {
1153                        "+ Add " (model_slug.trim_end_matches('s'))
1154                    }
1155                }
1156            };
1157            let body = html! {
1158                // Search. Hidden inputs preserve any active filters so both
1159                // full-form GET submits AND live-search HTMX requests carry
1160                // the filter set forward (htmx only includes the triggering
1161                // element by default — `hx-include="closest form"` pulls in
1162                // every input in the form, including the filter hiddens).
1163                form class="search-bar" method="get" {
1164                    input type="search" name="q" placeholder="Search…"
1165                        aria-label="Search records"
1166                        value=(search_query)
1167                        hx-get={ (prefix) "/" (model_slug) }
1168                        hx-trigger="input changed delay:300ms"
1169                        hx-include="closest form"
1170                        hx-target="closest .autumn-card"
1171                        hx-select=".autumn-card > *"
1172                        hx-push-url="true" {}
1173                    @for (k, v) in filters {
1174                        input type="hidden" name={ "filter." (k) } value=(v);
1175                    }
1176                }
1177
1178                // Bulk-action form wraps the table so the row checkboxes
1179                // submit alongside the action selector.
1180                form method="post" action={ (prefix) "/" (model_slug) "/actions" } {
1181                    (csrf_hidden_input(csrf_token, csrf_form_field))
1182
1183                // Table
1184                div class="table-wrap" {
1185                    table {
1186                        thead {
1187                            tr {
1188                                th class="checkbox-cell" {
1189                                    // Wired up by admin.js via event delegation on #select-all.
1190                                    input type="checkbox" id="select-all" aria-label="Select all rows";
1191                                }
1192                                @for field in &list_fields {
1193                                    @let is_sorted = sort_by == Some(field.name);
1194                                    @let next_dir = if is_sorted { sort_dir.flipped() } else { SortDirection::Asc };
1195                                    th {
1196                                        @if field.sortable {
1197                                            a href={ (prefix) "/" (model_slug) "?sort=" (field.name) "&dir=" (next_dir.as_str())
1198                                                @if !search_enc.is_empty() { "&q=" (search_enc) }
1199                                                (filters_enc)
1200                                            }
1201                                            style="color: inherit; text-decoration: none;" {
1202                                                (field.label)
1203                                                @if is_sorted {
1204                                                    span class="sort-icon" {
1205                                                        @if matches!(sort_dir, SortDirection::Asc) { "▲" } @else { "▼" }
1206                                                    }
1207                                                }
1208                                            }
1209                                        } @else {
1210                                            (field.label)
1211                                        }
1212                                    }
1213                                }
1214                                th { "Actions" }
1215                            }
1216                        }
1217                        tbody {
1218                            @if result.records.is_empty() {
1219                                tr {
1220                                    td colspan=(list_fields.len() + 2)
1221                                        style="text-align: center; padding: 2rem; color: var(--text-muted);" {
1222                                        "No records found."
1223                                    }
1224                                }
1225                            }
1226                            @for record in &result.records {
1227                                @let row_id = record_id(record);
1228                                tr {
1229                                    td class="checkbox-cell" {
1230                                        // Only emit a bulk-action checkbox for rows with a
1231                                        // routable id — otherwise the form would post id="" or
1232                                        // the wrong record.
1233                                        @if let Some(id) = row_id {
1234                                            input type="checkbox" class="row-check"
1235                                                aria-label="Select row"
1236                                                name="ids" value=(id);
1237                                        }
1238                                    }
1239                                    @for field in &list_fields {
1240                                        td { (render_cell_value(record, field)) }
1241                                    }
1242                                    td {
1243                                        @if let Some(id) = row_id {
1244                                            a href={ (prefix) "/" (model_slug) "/" (id) }
1245                                                class="btn btn-sm" { "View" }
1246                                            " "
1247                                            a href={ (prefix) "/" (model_slug) "/" (id) "/edit" }
1248                                                class="btn btn-sm" { "Edit" }
1249                                        } @else {
1250                                            // Surface the issue rather than rendering links to /0.
1251                                            span style="color: var(--text-muted); font-size: 0.75rem;" {
1252                                                "no id"
1253                                            }
1254                                        }
1255                                    }
1256                                }
1257                            }
1258                        }
1259                    }
1260                }
1261
1262                    // Bulk-action bar — only rendered when the model declares
1263                    // at least one action. Sits below the table inside the
1264                    // wrapping form.
1265                    @if !actions.is_empty() {
1266                        div class="action-bar" {
1267                            label for="bulk-action" { "With selected:" }
1268                            select name="action" id="bulk-action" class="form-input"
1269                                style="width: auto; display: inline-block;" {
1270                                @for a in actions {
1271                                    option value=(a.name) data-confirm=[a.confirm.then_some("1")] {
1272                                        (a.label)
1273                                    }
1274                                }
1275                            }
1276                            button type="submit" class="btn" data-bulk-submit="1" {
1277                                "Apply"
1278                            }
1279                        }
1280                    }
1281
1282                } // /form
1283
1284                // Shared confirm dialog for destructive bulk actions — rendered
1285                // once per page when at least one declared action requires
1286                // confirmation. admin.js intercepts the bulk form's submit,
1287                // shows this dialog instead of window.confirm(), and fills in
1288                // [data-bulk-confirm-detail] with the action/count description.
1289                @if actions.iter().any(|a| a.confirm) {
1290                    (modal(
1291                        "admin-bulk-confirm",
1292                        "Confirm bulk action",
1293                        &html! { p data-bulk-confirm-detail {} },
1294                        &ModalConfig::new().footer(html! {
1295                            (modal_close_button("Cancel", "admin-bulk-confirm", Some("btn")))
1296                            button type="button" class="btn btn-danger"
1297                                command="close" commandfor="admin-bulk-confirm"
1298                                data-modal-close="admin-bulk-confirm" data-bulk-confirm {
1299                                "Confirm"
1300                            }
1301                        }),
1302                    ))
1303                }
1304
1305                // Pagination
1306                @if result.total_pages() > 1 {
1307                    (render_pagination(result, model_slug, &search_enc, sort_by, sort_dir, &filters_enc, prefix))
1308                }
1309            };
1310            card(&body, &CardConfig::new().title_html(title).header_action(header_action))
1311        })
1312    };
1313    admin_layout(
1314        registry,
1315        Some(model_slug),
1316        model_name_plural,
1317        prefix,
1318        actuator_prefix,
1319        csrf_token,
1320        csrf_token_header,
1321        messages,
1322        show_config,
1323        impersonation,
1324        &content,
1325    )
1326}
1327
1328// ── CSV import form ──────────────────────────────────────────────────
1329
1330/// Render the CSV import upload form.
1331#[allow(clippy::too_many_arguments)]
1332pub fn model_import_form_page(
1333    registry: &AdminRegistry,
1334    model_slug: &str,
1335    model_name_plural: &str,
1336    messages: &[FlashMessage],
1337    csrf_token: &str,
1338    csrf_form_field: &str,
1339    csrf_token_header: &str,
1340    prefix: &str,
1341    actuator_prefix: &str,
1342    show_config: bool,
1343    impersonation: Option<&ImpersonationBanner>,
1344) -> Markup {
1345    let content = html! {
1346        div class="breadcrumbs" {
1347            a href=(prefix) { "Admin" }
1348            span class="sep" { "›" }
1349            a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
1350            span class="sep" { "›" }
1351            span { "Import CSV" }
1352        }
1353
1354        ({
1355            let title = html! { "Import " (model_name_plural) " from CSV" };
1356            let body = html! {
1357                p style="color: var(--text-muted); margin-bottom: 1rem;" {
1358                    "Upload a CSV file with a header row. Column names must match the model's field names."
1359                }
1360
1361                form id="autumn-csv-import-form"
1362                    method="post"
1363                    action={ (prefix) "/" (model_slug) "/import?" (csrf_form_field) "=" (csrf_token) }
1364                    enctype="multipart/form-data" {
1365
1366                    (csrf_hidden_input(csrf_token, csrf_form_field))
1367
1368                    div style="margin-bottom: 1rem;" {
1369                        label for="csv-file" style="display: block; margin-bottom: 0.25rem; font-weight: 500;" {
1370                            "CSV File"
1371                        }
1372                        input type="file" id="csv-file" name="file"
1373                            accept=".csv,text/csv"
1374                            required
1375                            class="form-input" {}
1376                    }
1377
1378                    div style="margin-bottom: 1.5rem;" {
1379                        label for="import-mode" style="display: block; margin-bottom: 0.25rem; font-weight: 500;" {
1380                            "Import Mode"
1381                        }
1382                        select id="import-mode" name="mode" class="form-input"
1383                            style="width: auto; display: inline-block;" {
1384                            option value="insert" selected { "Insert (add as new records)" }
1385                            option value="dry_run" { "Dry Run (validate only, no writes)" }
1386                        }
1387                    }
1388
1389                    div style="display: flex; gap: 0.75rem; align-items: center;" {
1390                        button type="submit" class="btn btn-primary" { "Upload and Import" }
1391                        a href={ (prefix) "/" (model_slug) } class="btn" { "Cancel" }
1392                    }
1393                }
1394
1395                div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border);" {
1396                    h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.5rem;" {
1397                        "Tips"
1398                    }
1399                    ul style="color: var(--text-muted); font-size: 0.875rem; padding-left: 1.25rem;" {
1400                        li { "The first row must be a header row with column names." }
1401                        li { "Column names must match the model's field names." }
1402                        li { "Use Dry Run to preview the import and catch errors before writing." }
1403                        li {
1404                            "Download a template: "
1405                            a href={ (prefix) "/" (model_slug) "/export.csv" } { "export.csv" }
1406                        }
1407                    }
1408                }
1409            };
1410            card(&body, &CardConfig::new().title_html(title))
1411        })
1412    };
1413
1414    admin_layout(
1415        registry,
1416        Some(model_slug),
1417        model_name_plural,
1418        prefix,
1419        actuator_prefix,
1420        csrf_token,
1421        csrf_token_header,
1422        messages,
1423        show_config,
1424        impersonation,
1425        &content,
1426    )
1427}
1428
1429/// Render the result page after a CSV import.
1430#[allow(clippy::too_many_arguments)]
1431pub fn model_import_result_page(
1432    registry: &AdminRegistry,
1433    model_slug: &str,
1434    model_name_plural: &str,
1435    report: &AdminImportReport,
1436    mode: CsvImportMode,
1437    messages: &[FlashMessage],
1438    csrf_token: &str,
1439    csrf_token_header: &str,
1440    prefix: &str,
1441    actuator_prefix: &str,
1442    show_config: bool,
1443    impersonation: Option<&ImpersonationBanner>,
1444) -> Markup {
1445    let mode_label = match mode {
1446        CsvImportMode::DryRun => "Dry Run",
1447        CsvImportMode::Insert => "Insert",
1448    };
1449    let total = report.inserted + report.updated + report.skipped + report.errors.len() as u64;
1450
1451    let content = html! {
1452        div class="breadcrumbs" {
1453            a href=(prefix) { "Admin" }
1454            span class="sep" { "›" }
1455            a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
1456            span class="sep" { "›" }
1457            span { "Import Result" }
1458        }
1459
1460        ({
1461            let title = html! { "Import Report — " (mode_label) };
1462            let body = html! {
1463                div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem;" {
1464                    div style="text-align: center; padding: 1rem; background: var(--success-light); border-radius: 0.375rem;" {
1465                        div style="font-size: 1.5rem; font-weight: 700; color: var(--success);" { (report.inserted) }
1466                        div style="font-size: 0.75rem; color: var(--text-muted);" { "Inserted" }
1467                    }
1468                    div style="text-align: center; padding: 1rem; background: var(--primary-light); border-radius: 0.375rem;" {
1469                        div style="font-size: 1.5rem; font-weight: 700; color: var(--primary);" { (report.updated) }
1470                        div style="font-size: 0.75rem; color: var(--text-muted);" { "Updated" }
1471                    }
1472                    div style="text-align: center; padding: 1rem; background: var(--border); border-radius: 0.375rem;" {
1473                        div style="font-size: 1.5rem; font-weight: 700;" { (report.skipped) }
1474                        div style="font-size: 0.75rem; color: var(--text-muted);" { "Skipped" }
1475                    }
1476                    div style="text-align: center; padding: 1rem; background: var(--danger-light); border-radius: 0.375rem;" {
1477                        div style="font-size: 1.5rem; font-weight: 700; color: var(--danger);" { (report.errors.len()) }
1478                        div style="font-size: 0.75rem; color: var(--text-muted);" { "Errors" }
1479                    }
1480                }
1481
1482                p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1.5rem;" {
1483                    "Processed " (total) " data rows."
1484                    @if matches!(mode, CsvImportMode::DryRun) {
1485                        " (Dry run — no records were written.)"
1486                    }
1487                }
1488
1489                @if !report.errors.is_empty() {
1490                    h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem; color: var(--danger);" {
1491                        "Row Errors"
1492                    }
1493                    div class="table-wrap" {
1494                        table {
1495                            thead {
1496                                tr {
1497                                    th { "Line" }
1498                                    th { "Column" }
1499                                    th { "Message" }
1500                                }
1501                            }
1502                            tbody {
1503                                @for err in &report.errors {
1504                                    tr {
1505                                        td { (err.line) }
1506                                        td {
1507                                            @if let Some(col) = &err.column {
1508                                                code { (col) }
1509                                            } @else {
1510                                                span style="color: var(--text-muted);" { "—" }
1511                                            }
1512                                        }
1513                                        td { (err.message) }
1514                                    }
1515                                }
1516                            }
1517                        }
1518                    }
1519                }
1520
1521                div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;" {
1522                    a href={ (prefix) "/" (model_slug) } class="btn btn-primary" { "Back to list" }
1523                    a href={ (prefix) "/" (model_slug) "/import" } class="btn" { "Import another file" }
1524                }
1525            };
1526            card(&body, &CardConfig::new().title_html(title))
1527        })
1528    };
1529
1530    admin_layout(
1531        registry,
1532        Some(model_slug),
1533        model_name_plural,
1534        prefix,
1535        actuator_prefix,
1536        csrf_token,
1537        csrf_token_header,
1538        messages,
1539        show_config,
1540        impersonation,
1541        &content,
1542    )
1543}
1544
1545// ── Detail view ─────────────────────────────────────────────────────
1546
1547/// Render the detail view for a single record.
1548#[allow(clippy::too_many_arguments)]
1549pub fn model_detail_page(
1550    registry: &AdminRegistry,
1551    model_slug: &str,
1552    model_name: &str,
1553    model_name_plural: &str,
1554    fields: &[AdminField],
1555    record: &Value,
1556    record_display: &str,
1557    // Path-based ID from the handler. Authoritative — edit/delete links
1558    // must route to the same record the URL addressed, not whatever ID
1559    // happens to appear in the JSON payload.
1560    id: i64,
1561    messages: &[FlashMessage],
1562    csrf_token: &str,
1563    csrf_form_field: &str,
1564    csrf_token_header: &str,
1565    prefix: &str,
1566    actuator_prefix: &str,
1567    has_history: bool,
1568    show_config: bool,
1569    impersonation: Option<&ImpersonationBanner>,
1570) -> Markup {
1571    let content = html! {
1572        div class="breadcrumbs" {
1573            a href=(prefix) { "Admin" }
1574            span class="sep" { "›" }
1575            a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
1576            span class="sep" { "›" }
1577            span { (record_display) }
1578        }
1579
1580        ({
1581            let delete_url = format!("{prefix}/{model_slug}/{id}");
1582            let delete_dialog_id = format!("delete-confirm-{id}");
1583            let delete_title = format!("Delete this {model_name}?");
1584            let header_action = html! {
1585                div class="header-actions" {
1586                    @if has_history {
1587                        a href={ (prefix) "/" (model_slug) "/" (id) "/history" }
1588                            class="btn btn-secondary" { "History" }
1589                        " "
1590                    }
1591                    a href={ (prefix) "/" (model_slug) "/" (id) "/edit" }
1592                        class="btn btn-primary" { "Edit" }
1593                    " "
1594                    (confirm_action(
1595                        &delete_dialog_id,
1596                        "Delete",
1597                        &delete_url,
1598                        Method::DELETE,
1599                        csrf_token,
1600                        &ConfirmActionConfig::new()
1601                            .title(&delete_title)
1602                            .message(html! { p { "This action cannot be undone." } })
1603                            .trigger_class("btn btn-danger")
1604                            .confirm_class("btn btn-danger")
1605                            .csrf_field(csrf_form_field),
1606                    ))
1607                }
1608            };
1609            let body = html! {
1610                div class="detail-grid" {
1611                    @for field in fields {
1612                        div class="detail-label" { (field.label) }
1613                        div class="detail-value" {
1614                            (render_detail_value(record, field))
1615                        }
1616                    }
1617                }
1618            };
1619            card(&body, &CardConfig::new().title(record_display).header_action(header_action))
1620        })
1621    };
1622    admin_layout(
1623        registry,
1624        Some(model_slug),
1625        record_display,
1626        prefix,
1627        actuator_prefix,
1628        csrf_token,
1629        csrf_token_header,
1630        messages,
1631        show_config,
1632        impersonation,
1633        &content,
1634    )
1635}
1636
1637// ── Create / Edit form ──────────────────────────────────────────────
1638
1639/// Render the create or edit form for a model.
1640#[allow(clippy::too_many_arguments)]
1641pub fn model_form_page(
1642    registry: &AdminRegistry,
1643    model_slug: &str,
1644    model_name: &str,
1645    model_name_plural: &str,
1646    fields: &[AdminField],
1647    record: Option<&Value>,
1648    // Path-based ID from the handler on edit pages (`None` when rendering
1649    // the "new" form). Never trust the JSON payload for mutation routing.
1650    id: Option<i64>,
1651    // Names of fields in `record` whose value is raw, unvalidated form
1652    // input from a failed submission rather than a genuine stored/coerced
1653    // value — see `render_form_widget`. Empty for every ordinary render
1654    // (fresh "new" form, a real stored record, or a resubmission that
1655    // failed for a reason unrelated to that field's own syntax).
1656    raw_fields: &[&str],
1657    messages: &[FlashMessage],
1658    csrf_token: &str,
1659    csrf_form_field: &str,
1660    csrf_token_header: &str,
1661    prefix: &str,
1662    actuator_prefix: &str,
1663    show_config: bool,
1664    impersonation: Option<&ImpersonationBanner>,
1665) -> Markup {
1666    let is_edit = id.is_some();
1667    let title = if is_edit {
1668        format!("Edit {model_name}")
1669    } else {
1670        format!("New {model_name}")
1671    };
1672
1673    let editable_fields: Vec<_> = fields.iter().filter(|f| f.editable).collect();
1674
1675    let content = html! {
1676        div class="breadcrumbs" {
1677            a href=(prefix) { "Admin" }
1678            span class="sep" { "›" }
1679            a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
1680            span class="sep" { "›" }
1681            span { (title) }
1682        }
1683
1684        ({
1685            let body = html! {
1686                form method="post"
1687                    action={
1688                        @if let Some(id) = id {
1689                            (prefix) "/" (model_slug) "/" (id)
1690                        } @else {
1691                            (prefix) "/" (model_slug)
1692                        }
1693                    } {
1694                    (csrf_hidden_input(csrf_token, csrf_form_field))
1695
1696                    @for field in &editable_fields {
1697                        div class="form-group" {
1698                            label class="form-label" for=(field.name) {
1699                                (field.label)
1700                                @if field.required && !(is_edit && field.create_only) {
1701                                    span class="required" { "*" }
1702                                }
1703                            }
1704                            @if is_edit && field.create_only {
1705                                // Immutable-after-create: show current value as read-only text
1706                                // so the admin can see it but cannot change it.
1707                                (render_readonly_display(field, record))
1708                            } @else {
1709                                (render_form_widget(field, record, is_edit, raw_fields))
1710                            }
1711                        }
1712                    }
1713
1714                    div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;" {
1715                        button type="submit" class="btn btn-primary" {
1716                            @if is_edit { "Save Changes" } @else { "Create" }
1717                        }
1718                        a href={ (prefix) "/" (model_slug) } class="btn" {
1719                            "Cancel"
1720                        }
1721                    }
1722                }
1723            };
1724            card(&body, &CardConfig::new().title(&title))
1725        })
1726    };
1727    admin_layout(
1728        registry,
1729        Some(model_slug),
1730        &title,
1731        prefix,
1732        actuator_prefix,
1733        csrf_token,
1734        csrf_token_header,
1735        messages,
1736        show_config,
1737        impersonation,
1738        &content,
1739    )
1740}
1741
1742// ── Runtime config page ─────────────────────────────────────────────
1743
1744/// Render the runtime config management page.
1745#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1746pub fn config_page(
1747    registry: &AdminRegistry,
1748    entries: &[ConfigEntry],
1749    messages: &[FlashMessage],
1750    csrf_token: &str,
1751    csrf_form_field: &str,
1752    csrf_token_header: &str,
1753    prefix: &str,
1754    actuator_prefix: &str,
1755    impersonation: Option<&ImpersonationBanner>,
1756) -> Markup {
1757    let content = html! {
1758        div class="breadcrumbs" {
1759            a href=(prefix) { "Admin" }
1760            span class="sep" { "›" }
1761            span { "Runtime Config" }
1762        }
1763
1764        h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem;" {
1765            "Runtime Config"
1766        }
1767        p style="color: var(--text-muted); margin-bottom: 1.5rem; font-size: 0.875rem;" {
1768            "Live-tunable operational knobs. Changes take effect immediately without a restart."
1769        }
1770
1771        @if entries.is_empty() {
1772            (card(&html! {
1773                p style="color: var(--text-muted); padding: 1rem;" {
1774                    "No config keys have been registered. Declare keys with "
1775                    code { "ConfigRegistry::define" }
1776                    " and pass the service via "
1777                    code { "AdminPlugin::with_runtime_config" }
1778                    "."
1779                }
1780            }, &CardConfig::new()))
1781        } @else {
1782            (card(&html! {
1783                table class="table" {
1784                    thead {
1785                        tr {
1786                            th { "Key" }
1787                            th { "Type" }
1788                            th { "Current Value" }
1789                            th { "Default" }
1790                            th { "Status" }
1791                            th { "Actions" }
1792                        }
1793                    }
1794                    tbody {
1795                        @for entry in entries {
1796                            tr {
1797                                td {
1798                                    strong { (entry.name) }
1799                                    @if let Some(desc) = &entry.description {
1800                                        br;
1801                                        span style="color: var(--text-muted); font-size: 0.8125rem;" {
1802                                            (desc)
1803                                        }
1804                                    }
1805                                }
1806                                td { code style="font-size: 0.8125rem;" { (entry.value_type) } }
1807                                td { code style="font-size: 0.8125rem;" { (entry.current.to_raw()) } }
1808                                td {
1809                                    code style="font-size: 0.8125rem; color: var(--text-muted);" {
1810                                        (entry.default.to_raw())
1811                                    }
1812                                }
1813                                td {
1814                                    @if entry.is_overridden {
1815                                        span style="color: var(--warning-text); font-size: 0.8125rem; font-weight: 500;" {
1816                                            "overridden"
1817                                        }
1818                                    } @else {
1819                                        span style="color: var(--text-muted); font-size: 0.8125rem;" {
1820                                            "default"
1821                                        }
1822                                    }
1823                                }
1824                                td {
1825                                    div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;" {
1826                                        form method="post"
1827                                            action={ (prefix) "/config/" (entry.name) "/set" }
1828                                            style="display: flex; gap: 0.25rem; align-items: center;" {
1829                                            (csrf_hidden_input(csrf_token, csrf_form_field))
1830                                            input type="text" name="value"
1831                                                aria-label=(format!("Value for {}", entry.name))
1832                                                value=(entry.current.to_raw())
1833                                                style="width: 11rem; font-size: 0.8125rem; padding: 0.25rem 0.5rem; border: 1px solid var(--border); border-radius: 0.25rem;" {}
1834                                            button type="submit" class="btn btn-sm btn-primary" { "Save" }
1835                                        }
1836                                        @if entry.is_overridden {
1837                                            form method="post"
1838                                                action={ (prefix) "/config/" (entry.name) "/unset" } {
1839                                                (csrf_hidden_input(csrf_token, csrf_form_field))
1840                                                button type="submit" class="btn btn-sm" { "Reset" }
1841                                            }
1842                                        }
1843                                        a href={ (prefix) "/config/" (entry.name) "/history" }
1844                                            class="btn btn-sm" { "History" }
1845                                    }
1846                                }
1847                            }
1848                        }
1849                    }
1850                }
1851            }, &CardConfig::new()))
1852        }
1853    };
1854    admin_layout(
1855        registry,
1856        Some(RUNTIME_CONFIG_NAV_SLUG),
1857        "Runtime Config",
1858        prefix,
1859        actuator_prefix,
1860        csrf_token,
1861        csrf_token_header,
1862        messages,
1863        true,
1864        impersonation,
1865        &content,
1866    )
1867}
1868
1869/// Render the change history page for a single config key.
1870#[allow(clippy::too_many_arguments)]
1871pub fn config_history_page(
1872    registry: &AdminRegistry,
1873    key: &str,
1874    history: &[ConfigChangeRecord],
1875    messages: &[FlashMessage],
1876    csrf_token: &str,
1877    csrf_token_header: &str,
1878    prefix: &str,
1879    actuator_prefix: &str,
1880    impersonation: Option<&ImpersonationBanner>,
1881) -> Markup {
1882    let title = format!("History: {key}");
1883    let content = html! {
1884        div class="breadcrumbs" {
1885            a href=(prefix) { "Admin" }
1886            span class="sep" { "›" }
1887            a href={ (prefix) "/config" } { "Runtime Config" }
1888            span class="sep" { "›" }
1889            span { (key) }
1890        }
1891
1892        h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 1rem;" {
1893            "History: " (key)
1894        }
1895
1896        (card(&html! {
1897            @if history.is_empty() {
1898                p style="color: var(--text-muted); padding: 1rem;" {
1899                    "No changes recorded for this key yet."
1900                }
1901            } @else {
1902                table class="table" {
1903                    thead {
1904                        tr {
1905                            th { "Timestamp (UTC)" }
1906                            th { "Actor" }
1907                            th { "Old Value" }
1908                            th { "New Value" }
1909                        }
1910                    }
1911                    tbody {
1912                        @for record in history {
1913                            tr {
1914                                td {
1915                                    code style="font-size: 0.8125rem;" {
1916                                        (format_timestamp(record.timestamp_secs))
1917                                    }
1918                                }
1919                                td { (record.actor.as_deref().unwrap_or("—")) }
1920                                td {
1921                                    @match &record.old_value {
1922                                        Some(v) => {
1923                                            code style="font-size: 0.8125rem;" { (v.to_raw()) }
1924                                        }
1925                                        None => {
1926                                            span style="color: var(--text-muted);" { "—" }
1927                                        }
1928                                    }
1929                                }
1930                                td {
1931                                    @match &record.new_value {
1932                                        Some(v) => {
1933                                            code style="font-size: 0.8125rem;" { (v.to_raw()) }
1934                                        }
1935                                        None => {
1936                                            span style="color: var(--text-muted); font-style: italic;" {
1937                                                "reset to default"
1938                                            }
1939                                        }
1940                                    }
1941                                }
1942                            }
1943                        }
1944                    }
1945                }
1946            }
1947        }, &CardConfig::new()))
1948
1949        a href={ (prefix) "/config" } class="btn" style="margin-top: 1rem;" {
1950            "← Back to Runtime Config"
1951        }
1952    };
1953    admin_layout(
1954        registry,
1955        Some(RUNTIME_CONFIG_NAV_SLUG),
1956        &title,
1957        prefix,
1958        actuator_prefix,
1959        csrf_token,
1960        csrf_token_header,
1961        messages,
1962        true,
1963        impersonation,
1964        &content,
1965    )
1966}
1967
1968fn format_timestamp(ts: u64) -> String {
1969    use chrono::{DateTime, Utc};
1970    let secs = i64::try_from(ts).unwrap_or(i64::MAX);
1971    DateTime::from_timestamp(secs, 0).map_or_else(
1972        || ts.to_string(),
1973        |dt: DateTime<Utc>| dt.format("%Y-%m-%d %H:%M:%S").to_string(),
1974    )
1975}
1976
1977// ── Rendering helpers ───────────────────────────────────────────────
1978
1979/// Render a cell value in the list table.
1980fn render_cell_value(record: &Value, field: &AdminField) -> Markup {
1981    // Defense in depth: the list-view field filter already excludes
1982    // `AdminFieldKind::Password`, but mask here too so we can never leak
1983    // a hash if a caller slips one through.
1984    if matches!(field.kind, AdminFieldKind::Password) {
1985        return html! { "••••••••" };
1986    }
1987    // At-rest encrypted columns (#805) are redacted in admin views by default.
1988    // Rendering decrypted plaintext is a per-field opt-in (`encrypted_visible`,
1989    // from `#[encrypted(admin_visible)]`) gated through the admin policy
1990    // machinery (#496). The flag is per-field (not a global column-name lookup)
1991    // so an unrelated same-named plaintext column is unaffected.
1992    if field.encrypted && !field.encrypted_visible {
1993        return html! { span title="encrypted at rest" { "••••••••" } };
1994    }
1995    // #1771: a `#[confidential]` column holds an envelope the operator cannot
1996    // open, so the admin shows a mask rather than base64 nobody can read. The
1997    // lookup is by column name, which errs toward privacy: a same-named column
1998    // on another table is masked too.
1999    if ::autumn_web::confidential::is_confidential_column_name(field.name) {
2000        return html! { span title="sealed for its owner" { "••••••••" } };
2001    }
2002    let val = record.get(field.name);
2003    match val {
2004        None | Some(Value::Null) => html! {
2005            span style="color: var(--text-muted);" { "—" }
2006        },
2007        Some(Value::Bool(b)) => html! {
2008            @if *b {
2009                span style="color: var(--success-text);" { "✓" }
2010            } @else {
2011                span style="color: var(--text-muted);" { "✗" }
2012            }
2013        },
2014        Some(Value::String(s)) => html! { (truncate_display(s, 80)) },
2015        Some(v) => html! { (v) },
2016    }
2017}
2018
2019/// Percent-encode a query-component value.
2020///
2021/// Conservative: escapes everything that isn't an unreserved URL character
2022/// (RFC 3986 `unreserved`: `A-Z a-z 0-9 - . _ ~`). Safe for both path and
2023/// query contexts.
2024fn url_encode(s: &str) -> String {
2025    let mut out = String::with_capacity(s.len());
2026    for b in s.as_bytes() {
2027        match *b {
2028            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
2029                out.push(*b as char);
2030            }
2031            other => {
2032                use std::fmt::Write;
2033                let _ = write!(out, "%{other:02X}");
2034            }
2035        }
2036    }
2037    out
2038}
2039
2040/// Encode active filters as a URL suffix, e.g.
2041/// `&filter.status=active&filter.tier=premium`. Empty when no filters are
2042/// active. Both keys and values are percent-encoded so values containing
2043/// `&`, `=`, or non-ASCII characters round-trip correctly.
2044fn encode_filter_suffix(filters: &[(String, String)]) -> String {
2045    let mut out = String::new();
2046    for (k, v) in filters {
2047        out.push_str("&filter.");
2048        out.push_str(&url_encode(k));
2049        out.push('=');
2050        out.push_str(&url_encode(v));
2051    }
2052    out
2053}
2054
2055/// UTF-8-safe truncation by character count. Appends `…` if truncated.
2056fn truncate_display(s: &str, max_chars: usize) -> String {
2057    if s.chars().count() <= max_chars {
2058        return s.to_owned();
2059    }
2060    let keep = max_chars.saturating_sub(1);
2061    let mut out: String = s.chars().take(keep).collect();
2062    out.push('…');
2063    out
2064}
2065
2066/// Normalize a stored date string into `YYYY-MM-DD`, the only format the
2067/// HTML `<input type="date">` control accepts. Leaves the input untouched
2068/// if it can't be parsed — the user sees whatever the backend sent rather
2069/// than a silently-empty field.
2070fn normalize_date_input(s: &str) -> String {
2071    if s.is_empty() {
2072        return String::new();
2073    }
2074    // Fast path: already in the right shape.
2075    if chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
2076        return s.to_owned();
2077    }
2078    // Fall back to full RFC 3339 (which includes the `T` + time).
2079    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
2080        return dt.format("%Y-%m-%d").to_string();
2081    }
2082    // Finally try a naive datetime.
2083    if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
2084        return ndt.format("%Y-%m-%d").to_string();
2085    }
2086    s.to_owned()
2087}
2088
2089/// Normalize a stored datetime string into `YYYY-MM-DDTHH:MM`, the only
2090/// format the HTML `<input type="datetime-local">` control accepts.
2091/// Browsers silently reject RFC 3339 with `Z`/offset; this maps common
2092/// backend representations onto the local-time shape the input expects.
2093///
2094/// **Wall-time preserved.** For RFC 3339 inputs with an explicit offset,
2095/// the offset is dropped but the local clock components are kept as-is
2096/// (we use `naive_local()`, not `naive_utc()`). That way an unchanged
2097/// edit-save round trip doesn't shift the timestamp — `12:34+05:30`
2098/// renders as `12:34`, posts back unchanged as `12:34`, and the model can
2099/// re-attach whatever offset it wants.
2100///
2101/// If parsing fails, the original string is returned unchanged — better
2102/// to show the server's value than silently blank the field on edit.
2103fn normalize_datetime_local_input(s: &str) -> String {
2104    if s.is_empty() {
2105        return String::new();
2106    }
2107    // Already local-shaped.
2108    if chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M").is_ok() {
2109        return s.to_owned();
2110    }
2111    if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
2112        return ndt.format("%Y-%m-%dT%H:%M").to_string();
2113    }
2114    // RFC 3339 with timezone — keep the local wall-clock components and
2115    // drop the offset (don't shift to UTC; that would mutate the value
2116    // on a no-op save).
2117    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
2118        return dt.naive_local().format("%Y-%m-%dT%H:%M").to_string();
2119    }
2120    s.to_owned()
2121}
2122
2123/// Render a field value in the detail view.
2124fn render_detail_value(record: &Value, field: &AdminField) -> Markup {
2125    // Encrypted columns (#805) are redacted by default in admin detail views; the
2126    // `encrypted_visible` opt-in shows plaintext. Per-field, not a name lookup.
2127    if field.encrypted && !field.encrypted_visible {
2128        return html! { span title="encrypted at rest" { "••••••••" } };
2129    }
2130    // #1771: a `#[confidential]` column holds an envelope the operator cannot
2131    // open, so the admin shows a mask rather than base64 nobody can read. The
2132    // lookup is by column name, which errs toward privacy: a same-named column
2133    // on another table is masked too.
2134    if ::autumn_web::confidential::is_confidential_column_name(field.name) {
2135        return html! { span title="sealed for its owner" { "••••••••" } };
2136    }
2137    let val = record.get(field.name);
2138    match val {
2139        None | Some(Value::Null) => html! {
2140            span style="color: var(--text-muted);" { "—" }
2141        },
2142        Some(Value::Bool(b)) => html! {
2143            @if *b { "Yes" } @else { "No" }
2144        },
2145        Some(Value::String(s)) => {
2146            if matches!(field.kind, AdminFieldKind::Password) {
2147                html! { "••••••••" }
2148            } else if matches!(field.kind, AdminFieldKind::TextArea | AdminFieldKind::Json) {
2149                html! {
2150                    pre style="white-space: pre-wrap; font-size: 0.8125rem; background: var(--bg); padding: 0.75rem; border-radius: 0.375rem;" {
2151                        (s)
2152                    }
2153                }
2154            } else {
2155                html! { (s) }
2156            }
2157        }
2158        // Objects and arrays pretty-printed inside <pre>; plain text so
2159        // Maud HTML-escapes attacker-controlled content. PreEscaped here
2160        // would be a stored-XSS sink.
2161        Some(v) => html! {
2162            pre style="white-space: pre-wrap; font-size: 0.8125rem; background: var(--bg); padding: 0.75rem; border-radius: 0.375rem;" {
2163                (serde_json::to_string_pretty(v).unwrap_or_default())
2164            }
2165        },
2166    }
2167}
2168
2169/// Render a read-only display for a create-only field on the edit form.
2170///
2171/// Shows the current value as static text with no form control so the admin
2172/// can see it but cannot alter it (and it is never submitted to the server).
2173fn render_readonly_display(field: &AdminField, record: Option<&Value>) -> Markup {
2174    // #1771: a `create_only` column reaches this instead of `render_form_widget`
2175    // on EDIT, so the mask has to be here too. Redacting in the renderer rather
2176    // than at the one call site keeps a future caller from reopening the hole.
2177    if ::autumn_web::confidential::is_confidential_column_name(field.name) {
2178        return html! {
2179            p class="form-static-value" style="margin: 0; padding: 0.375rem 0; color: #555;" {
2180                span title="sealed for its owner" { "••••••••" }
2181            }
2182            small class="form-help" style="color: #888;" {
2183                "This field cannot be changed after creation."
2184            }
2185        };
2186    }
2187    let value = record
2188        .and_then(|r| r.get(field.name))
2189        .map(|v| match v {
2190            Value::String(s) => s.clone(),
2191            other => other.to_string(),
2192        })
2193        .unwrap_or_default();
2194    html! {
2195        p class="form-static-value" style="margin: 0; padding: 0.375rem 0; color: #555;" {
2196            (value)
2197        }
2198        small class="form-help" style="color: #888;" {
2199            "This field cannot be changed after creation."
2200        }
2201    }
2202}
2203
2204/// Render a form widget for a field.
2205///
2206/// `is_edit` is the CREATE-vs-EDIT signal, taken from the caller's `id`
2207/// (`None` on create) rather than inferred from `record.is_some()`: a
2208/// validation-failure redisplay on CREATE passes `Some(record)` (the
2209/// resubmitted values) with no `id`, which must still be treated as create
2210/// for the encrypted-field branch below (Codex review, PR #2422).
2211///
2212/// `raw_fields` names the fields (by `AdminField::name`) whose `record`
2213/// value is unvalidated form input straight from a failed submission —
2214/// e.g. text that failed `Json` parsing — rather than a genuinely typed
2215/// value from storage or a successful coercion. Only the `Json` branch
2216/// consults it, to skip the JSON-round-trip serialization and show the raw
2217/// text the admin typed instead (Codex review, PR #2422).
2218#[allow(clippy::too_many_lines)]
2219fn render_form_widget(
2220    field: &AdminField,
2221    record: Option<&Value>,
2222    is_edit: bool,
2223    raw_fields: &[&str],
2224) -> Markup {
2225    // Encrypted columns (#805). On EDIT we must never reveal or overwrite the
2226    // stored ciphertext, so render a disabled, redacted control with no
2227    // `name`: the plaintext never reaches the HTML and a save never submits
2228    // (and thus never overwrites) it. On CREATE there is no stored secret to
2229    // protect and the generated `New*` DTO requires the value, so fall
2230    // through to a normal editable input that captures the initial plaintext
2231    // (the wrapper encrypts it on insert). The flag is per-field, so an
2232    // unrelated same-named plaintext column stays editable.
2233    // #1771: a confidential column is never editable from the admin, on create
2234    // or on edit: sealing needs the owner's key, which the server never holds.
2235    if ::autumn_web::confidential::is_confidential_column_name(field.name) {
2236        return html! {
2237            input type="text" class="form-input" value="••••••••" disabled
2238                title="Sealed for its owner — the server cannot read or write it";
2239        };
2240    }
2241    if field.encrypted && is_edit {
2242        return html! {
2243            input type="text" class="form-input" value="••••••••" disabled
2244                title="Encrypted at rest — managed outside the admin";
2245        };
2246    }
2247    // An encrypted field that reaches here is CREATE (the EDIT branch above
2248    // already returned). A create-validation-failure redisplay still passes
2249    // `Some(record)` with the admin's just-typed plaintext in it — never
2250    // echo that back into the response body; keep the control enabled and
2251    // submittable (unlike EDIT) but blank, so the admin retypes it rather
2252    // than the secret round-tripping through server-rendered HTML a second
2253    // time (Codex review, PR #2422).
2254    let current_value = if field.encrypted {
2255        Value::Null
2256    } else {
2257        record
2258            .and_then(|r| r.get(field.name))
2259            .cloned()
2260            .unwrap_or(Value::Null)
2261    };
2262    let str_val = match &current_value {
2263        Value::String(s) => s.clone(),
2264        Value::Null => String::new(),
2265        v => v.to_string(),
2266    };
2267
2268    match &field.kind {
2269        AdminFieldKind::Text => html! {
2270            input type="text" class="form-input" name=(field.name) id=(field.name)
2271                value=(str_val)
2272                required[field.required];
2273        },
2274        AdminFieldKind::TextArea => html! {
2275            textarea class="form-input" name=(field.name) id=(field.name)
2276                required[field.required] {
2277                (str_val)
2278            }
2279        },
2280        AdminFieldKind::Integer => html! {
2281            input type="number" class="form-input" name=(field.name) id=(field.name)
2282                value=(str_val) step="1"
2283                required[field.required];
2284        },
2285        AdminFieldKind::Float => html! {
2286            input type="number" class="form-input" name=(field.name) id=(field.name)
2287                value=(str_val) step="any"
2288                required[field.required];
2289        },
2290        AdminFieldKind::Boolean => {
2291            let checked = matches!(current_value, Value::Bool(true));
2292            html! {
2293                input type="hidden" name=(field.name) value="false";
2294                input type="checkbox" name=(field.name) id=(field.name)
2295                    value="true" checked[checked]
2296                    style="width: auto;";
2297            }
2298        }
2299        AdminFieldKind::Date => {
2300            let v = normalize_date_input(&str_val);
2301            html! {
2302                input type="date" class="form-input" name=(field.name) id=(field.name)
2303                    value=(v)
2304                    required[field.required];
2305            }
2306        }
2307        AdminFieldKind::DateTime => {
2308            let v = normalize_datetime_local_input(&str_val);
2309            html! {
2310                input type="datetime-local" class="form-input" name=(field.name) id=(field.name)
2311                    value=(v)
2312                    required[field.required];
2313            }
2314        }
2315        AdminFieldKind::Select(options) => html! {
2316            select class="form-input" name=(field.name) id=(field.name)
2317                required[field.required] {
2318                option value="" { "— Select —" }
2319                @for opt in options {
2320                    option value=(opt.value)
2321                        selected[str_val == opt.value] {
2322                        (opt.label)
2323                    }
2324                }
2325            }
2326        },
2327        AdminFieldKind::Hidden => html! {
2328            input type="hidden" name=(field.name) value=(str_val);
2329        },
2330        AdminFieldKind::Password => html! {
2331            input type="password" class="form-input" name=(field.name) id=(field.name)
2332                placeholder="Leave blank to keep current"
2333                autocomplete="new-password";
2334        },
2335        AdminFieldKind::Json => {
2336            // `str_val` above unwraps a `Value::String` to its raw text (right
2337            // for Text/TextArea, where the stored value *is* plain text), but a
2338            // JSON textarea round-trips through `coerce_form_value`'s
2339            // `serde_json::from_str`, which needs JSON syntax back — a stored
2340            // top-level string like `"hello"` must render WITH its quotes, or a
2341            // pure no-op resave either fails to parse (`hello` isn't valid JSON)
2342            // or silently changes type (a stored string `"true"`/`"42"` reparses
2343            // as a bool/number).
2344            //
2345            // `current_value` collapses two different situations into the same
2346            // `Value::Null` — no `record` at all (CREATE: nothing to prefill,
2347            // must always render blank regardless of required-ness) and a
2348            // REQUIRED column's genuinely-stored JSON scalar `null` (EDIT: a
2349            // NOT NULL JSONB column can still hold `null` — it just can't be
2350            // SQL NULL — so blank would be wrong: it'd let the browser's
2351            // `required` attribute block saving any OTHER field without the
2352            // admin re-typing `null` by hand, and a programmatic blank
2353            // submission would coerce into the string `""` instead of
2354            // round-tripping back to `Value::Null`). So this reads `record`
2355            // directly instead of reusing `current_value`, matching only a
2356            // genuinely nullable-and-null EDIT to blank (Codex review finding
2357            // on #1341).
2358            // A field named in `raw_fields` holds the exact text the admin
2359            // typed, which failed to parse as JSON — show it verbatim so
2360            // they can see and fix the mistake, rather than round-tripping
2361            // it through `Value::to_string()` (which would wrap it in an
2362            // extra pair of quotes, turning `{broken` into the seemingly
2363            // valid JSON string `"{broken"` and risking a silent resave of
2364            // the wrong data — Codex review, PR #2422).
2365            let json_val = if raw_fields.contains(&field.name) || field.encrypted {
2366                // `field.encrypted`: this arm otherwise reads `record`
2367                // directly (see the #1341 comment above) rather than the
2368                // already-cleared `current_value`/`str_val`, which would
2369                // still leak an encrypted JSON field's submitted plaintext
2370                // on a create-failure redisplay (Codex review, PR #2422).
2371                // `str_val` is `""` here in both cases: it comes from
2372                // `current_value`, which is already forced to `Value::Null`
2373                // for every encrypted field above.
2374                str_val
2375            } else {
2376                match record.and_then(|r| r.get(field.name)) {
2377                    None => String::new(),
2378                    Some(Value::Null) if !field.required => String::new(),
2379                    Some(v) => v.to_string(),
2380                }
2381            };
2382            html! {
2383                textarea class="form-input" name=(field.name) id=(field.name)
2384                    style="font-family: monospace; min-height: 150px;"
2385                    required[field.required] {
2386                    (json_val)
2387                }
2388            }
2389        }
2390    }
2391}
2392
2393/// Render pagination controls.
2394///
2395/// `search_enc` and `filters_enc` are expected to be already URL-encoded;
2396/// callers pass the raw form for rendering and the encoded form for link
2397/// building.
2398#[allow(clippy::too_many_arguments)]
2399fn render_pagination(
2400    result: &ListResult,
2401    model_slug: &str,
2402    search_enc: &str,
2403    sort_by: Option<&str>,
2404    sort_dir: SortDirection,
2405    filters_enc: &str,
2406    prefix: &str,
2407) -> Markup {
2408    let current = result.page.max(1);
2409
2410    // The fixed portion of the query string — filters, sort, and search to
2411    // preserve across page clicks. The shared pager appends the `page` param.
2412    let query = {
2413        let mut s = String::new();
2414        if !search_enc.is_empty() {
2415            s.push_str("q=");
2416            s.push_str(search_enc);
2417        }
2418        if let Some(sort) = sort_by {
2419            if !s.is_empty() {
2420                s.push('&');
2421            }
2422            s.push_str("sort=");
2423            s.push_str(&url_encode(sort));
2424            s.push_str("&dir=");
2425            s.push_str(sort_dir.as_str());
2426        }
2427        if !filters_enc.is_empty() {
2428            // filters_enc is pre-encoded as `&filter.k=v&…`; merge it in.
2429            let trimmed = filters_enc.strip_prefix('&').unwrap_or(filters_enc);
2430            if !s.is_empty() {
2431                s.push('&');
2432            }
2433            s.push_str(trimmed);
2434        }
2435        s
2436    };
2437
2438    let start = if result.total == 0 {
2439        0
2440    } else {
2441        result
2442            .per_page
2443            .saturating_mul(current.saturating_sub(1))
2444            .saturating_add(1)
2445    };
2446    let end = start
2447        .saturating_add(result.per_page)
2448        .saturating_sub(1)
2449        .min(result.total);
2450
2451    let meta = page_meta(current, result.per_page, result.total, result.total_pages());
2452    let base = format!("{prefix}/{model_slug}");
2453    let opts = PagerOptions::new(&base)
2454        .query(&query)
2455        .window(1)
2456        .prev_label("← Prev")
2457        .next_label("Next →");
2458
2459    html! {
2460        div class="pagination" {
2461            span {
2462                "Showing " (start) "–" (end) " of " (result.total)
2463            }
2464            (pagination_nav(&meta, &opts))
2465        }
2466    }
2467}
2468
2469fn page_meta(page: u64, per_page: u64, total: u64, total_pages: u64) -> Page<()> {
2470    let to_u32 = |v: u64| u32::try_from(v).unwrap_or(u32::MAX);
2471    Page::from_raw(to_u32(page), to_u32(per_page), total, to_u32(total_pages))
2472}
2473
2474// -- Version history pane ----------------------------------------------------
2475
2476/// Render the version history pane for an opted-in model record.
2477///
2478/// Called by `GET /admin/{slug}/{id}/history`. Lists entries in
2479/// chronological order with actor, timestamp, and column-level diff.
2480#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2481pub fn model_history_page(
2482    registry: &AdminRegistry,
2483    model_slug: &str,
2484    model_name: &str,
2485    model_name_plural: &str,
2486    record_id_val: i64,
2487    history: &AdminHistoryPage,
2488    prefix: &str,
2489    actuator_prefix: &str,
2490    csrf_token_header: &str,
2491    show_config: bool,
2492    impersonation: Option<&ImpersonationBanner>,
2493) -> Markup {
2494    let record_display = format!("{model_name} #{record_id_val}");
2495    let history_page_href = |page: u64| {
2496        format!(
2497            "{prefix}/{model_slug}/{record_id_val}/history?page={page}&per_page={}",
2498            history.per_page
2499        )
2500    };
2501    let empty_messages: &[FlashMessage] = &[];
2502    let content = html! {
2503        div class="breadcrumbs" {
2504            a href=(prefix) { "Admin" }
2505            span class="sep" { "›" }
2506            a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
2507            span class="sep" { "›" }
2508            a href={ (prefix) "/" (model_slug) "/" (record_id_val) } { (record_display) }
2509            span class="sep" { "›" }
2510            span { "History" }
2511        }
2512
2513        ({
2514            let header_action = html! { small { " " (history.total) " entries" } };
2515            let body = html! {
2516                @if history.entries.is_empty() {
2517                    p class="text-muted" style="padding:1rem" { "No history entries yet." }
2518                } @else {
2519                    table class="admin-table" {
2520                        thead {
2521                            tr {
2522                                th { "#" }
2523                                th { "Operation" }
2524                                th { "Actor" }
2525                                th { "Request ID" }
2526                                th { "Changes" }
2527                                th { "Recorded At" }
2528                            }
2529                        }
2530                        tbody {
2531                            @for entry in &history.entries {
2532                                tr {
2533                                    td { (entry.id) }
2534                                    td {
2535                                        span class={ "badge badge-" (entry.op) } { (entry.op) }
2536                                    }
2537                                    td { code { (entry.actor) } }
2538                                    td {
2539                                        @if let Some(ref req_id) = entry.request_id {
2540                                            code class="text-muted" { (req_id) }
2541                                        } @else {
2542                                            span class="text-muted" { "—" }
2543                                        }
2544                                    }
2545                                    td {
2546                                        @if entry.changes.is_empty() {
2547                                            span class="text-muted" { "no changes" }
2548                                        } @else {
2549                                            details {
2550                                                summary { (entry.changes.len()) " column(s)" }
2551                                                ul class="change-list" {
2552                                                    @for change in &entry.changes {
2553                                                        li {
2554                                                            @if let Some(col) = change.get("column").and_then(Value::as_str) {
2555                                                                code { (col) }
2556                                                            }
2557                                                            @if change.get("sensitive").and_then(Value::as_bool).unwrap_or(false) {
2558                                                                span class="badge-sensitive" { " [sensitive]" }
2559                                                            } @else {
2560                                                                " "
2561                                                                span class="text-muted" { "before: " }
2562                                                                @if let Some(before) = change.get("before") {
2563                                                                    code { (before) }
2564                                                                } @else {
2565                                                                    em { "null" }
2566                                                                }
2567                                                                " → "
2568                                                                span class="text-muted" { "after: " }
2569                                                                @if let Some(after) = change.get("after") {
2570                                                                    code { (after) }
2571                                                                } @else {
2572                                                                    em { "null" }
2573                                                                }
2574                                                            }
2575                                                        }
2576                                                    }
2577                                                }
2578                                            }
2579                                        }
2580                                    }
2581                                    td {
2582                                        time datetime=(entry.recorded_at.to_rfc3339()) {
2583                                            (entry.recorded_at.format("%Y-%m-%d %H:%M:%S UTC"))
2584                                        }
2585                                    }
2586                                }
2587                            }
2588                        }
2589                    }
2590
2591                    @if history.total_pages() > 1 {
2592                        div class="pagination" {
2593                            @if history.page > 1 {
2594                                a href=(history_page_href(history.page - 1))
2595                                    class="btn btn-secondary btn-sm" { "← Prev" }
2596                            }
2597                            span { " Page " (history.page) " of " (history.total_pages()) " " }
2598                            @if history.has_next_page() {
2599                                a href=(history_page_href(history.page + 1))
2600                                    class="btn btn-secondary btn-sm" { "Next →" }
2601                            }
2602                        }
2603                    }
2604                }
2605            };
2606            card(&body, &CardConfig::new().title("Version History").header_action(header_action))
2607        })
2608    };
2609    admin_layout(
2610        registry,
2611        Some(model_slug),
2612        &record_display,
2613        prefix,
2614        actuator_prefix,
2615        "",
2616        csrf_token_header,
2617        empty_messages,
2618        show_config,
2619        impersonation,
2620        &content,
2621    )
2622}
2623
2624#[cfg(test)]
2625mod tests {
2626    use super::*;
2627
2628    // WCAG 1.4.3 (contrast minimum) relative-luminance / contrast-ratio
2629    // formulas, applied to the admin plugin's own `tokens.css` color pairs.
2630    // `#RRGGBB` only — every color literal in `tokens.css` is that shape.
2631    fn hex_channel(hex: &str, i: usize) -> f64 {
2632        f64::from(u8::from_str_radix(&hex[1 + i * 2..3 + i * 2], 16).unwrap()) / 255.0
2633    }
2634
2635    fn relative_luminance(hex: &str) -> f64 {
2636        let f = |c: f64| {
2637            if c <= 0.039_28 {
2638                c / 12.92
2639            } else {
2640                ((c + 0.055) / 1.055).powf(2.4)
2641            }
2642        };
2643        0.0722f64.mul_add(
2644            f(hex_channel(hex, 2)),
2645            0.2126f64.mul_add(f(hex_channel(hex, 0)), 0.7152 * f(hex_channel(hex, 1))),
2646        )
2647    }
2648
2649    fn contrast_ratio(a: &str, b: &str) -> f64 {
2650        let (la, lb) = (relative_luminance(a), relative_luminance(b));
2651        let (lighter, darker) = if la >= lb { (la, lb) } else { (lb, la) };
2652        (lighter + 0.05) / (darker + 0.05)
2653    }
2654
2655    // Audited 2026-09-04 (Wayfinder). The core admin CRUD loop (list → create
2656    // → edit, the reason the plugin exists — every registered model routes
2657    // through it) removed `:focus`'s outline on every text/select/textarea/
2658    // date input and the list page's search box, replacing it with
2659    // `border-color` + `box-shadow` only. `box-shadow` (and often
2660    // `border-color`) is suppressed under forced-colors mode (Windows High
2661    // Contrast and equivalent OS/browser settings), so a keyboard user in
2662    // that mode tabbing through the create/edit form saw *no* focus
2663    // indicator at all on any field — a WCAG 2.4.7 (Focus Visible) failure,
2664    // and exactly the "style away focus outlines without an equal-or-better
2665    // replacement" anti-pattern this persona is instructed never to ship.
2666    // The framework already has a real convention for this everywhere else
2667    // (`autumn/src/ui/widgets.css` uses `outline: 2px solid var(--primary);
2668    // outline-offset: 2px;` on 8 separate focus states, and this same file's
2669    // skip-link at line ~53 does too) — these two rules were the outliers.
2670    // Fix: restore that same outline (forced-colors mode renders any
2671    // non-`none` outline using the system's own focus color, so it can't be
2672    // silently stripped) alongside the existing border/box-shadow, which
2673    // keeps the current visual treatment in normal rendering.
2674    #[test]
2675    fn form_and_search_input_focus_keeps_a_visible_outline() {
2676        assert!(
2677            !ADMIN_CSS.contains("outline: none"),
2678            "a form/search input :focus rule dropped its outline with no \
2679             equal-or-better replacement — box-shadow/border-color alone are \
2680             stripped under forced-colors mode, leaving keyboard users with \
2681             no visible focus indicator: {ADMIN_CSS}"
2682        );
2683        for selector in [".form-input:focus", ".search-bar input:focus"] {
2684            let start = ADMIN_CSS
2685                .find(selector)
2686                .unwrap_or_else(|| panic!("missing `{selector}` rule in ADMIN_CSS"));
2687            let block_end = ADMIN_CSS[start..]
2688                .find('}')
2689                .map_or(ADMIN_CSS.len(), |i| start + i);
2690            let block = &ADMIN_CSS[start..block_end];
2691            assert!(
2692                block.contains("outline: 2px solid var(--primary)"),
2693                "`{selector}` must keep a visible outline: {block}"
2694            );
2695        }
2696    }
2697
2698    // Audited 2026-09-02 (Wayfinder). Config page (100% of admin-plugin
2699    // deployments that register runtime-config keys) and every model list
2700    // page's boolean columns both rendered their status text straight from
2701    // `--warning`/`--success` on `--surface`: 3.19:1 and 3.77:1, both below
2702    // WCAG AA's 4.5:1 normal-text threshold (the raw tokens are calibrated
2703    // for the 3:1 large-text/border/icon uses they already had, not small
2704    // foreground text). `--warning-text`/`--success-text` are the same hue
2705    // darkened to the framework's existing flash-message foreground shade,
2706    // reused here rather than inventing a new color.
2707    const SURFACE: &str = "#ffffff";
2708    const WARNING: &str = "#d97706";
2709    const WARNING_TEXT: &str = "#92400e";
2710    const SUCCESS: &str = "#059669";
2711    const SUCCESS_TEXT: &str = "#065f46";
2712
2713    #[test]
2714    fn raw_warning_and_success_tokens_fail_wcag_aa_text_contrast_on_surface() {
2715        // Documents why `--warning`/`--success` may not be used directly as
2716        // small/normal foreground text — the defect `-text` variants fix.
2717        assert!(
2718            contrast_ratio(WARNING, SURFACE) < 4.5,
2719            "if this now passes, --warning's hex changed and the -text variant may be redundant"
2720        );
2721        assert!(
2722            contrast_ratio(SUCCESS, SURFACE) < 4.5,
2723            "if this now passes, --success's hex changed and the -text variant may be redundant"
2724        );
2725    }
2726
2727    #[test]
2728    fn text_safe_warning_and_success_tokens_meet_wcag_aa_contrast_on_surface() {
2729        assert!(
2730            contrast_ratio(WARNING_TEXT, SURFACE) >= 4.5,
2731            "--warning-text on --surface must clear WCAG AA 4.5:1"
2732        );
2733        assert!(
2734            contrast_ratio(SUCCESS_TEXT, SURFACE) >= 4.5,
2735            "--success-text on --surface must clear WCAG AA 4.5:1"
2736        );
2737        // tokens.css is the source of truth; keep these hex literals honest.
2738        let css = include_str!("tokens.css");
2739        assert!(
2740            css.contains(&format!("--warning-text: {WARNING_TEXT}")),
2741            "{css}"
2742        );
2743        assert!(
2744            css.contains(&format!("--success-text: {SUCCESS_TEXT}")),
2745            "{css}"
2746        );
2747    }
2748
2749    #[test]
2750    fn config_page_overridden_status_uses_text_safe_warning_token() {
2751        use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
2752
2753        let r = dummy_registry();
2754        let entries = vec![ConfigEntry {
2755            name: "rate_limit".to_owned(),
2756            value_type: ConfigValueType::Int,
2757            current: ConfigValue::Int(200),
2758            default: ConfigValue::Int(100),
2759            is_overridden: true,
2760            description: None,
2761        }];
2762        let html = config_page(
2763            &r,
2764            &entries,
2765            &[],
2766            "tok",
2767            "_csrf",
2768            "X-CSRF-Token",
2769            "/admin",
2770            "/actuator",
2771            None,
2772        )
2773        .into_string();
2774        assert!(
2775            html.contains("color: var(--warning-text)"),
2776            "overridden status must use the text-safe warning token, not raw --warning: {html}"
2777        );
2778        assert!(!html.contains("color: var(--warning);"), "{html}");
2779    }
2780
2781    #[test]
2782    fn boolean_true_cell_uses_text_safe_success_token() {
2783        let record = serde_json::json!({ "active": true });
2784        let field = AdminField::new("active", AdminFieldKind::Boolean);
2785        let cell = render_cell_value(&record, &field).into_string();
2786        assert!(
2787            cell.contains("color: var(--success-text)"),
2788            "boolean-true cell must use the text-safe success token, not raw --success: {cell}"
2789        );
2790        assert!(!cell.contains("color: var(--success);"), "{cell}");
2791    }
2792
2793    // A model with at-rest encrypted columns (#805): `ssn` is redacted by default;
2794    // `audit_note` opts into `admin_visible` (shown in read views). The per-field
2795    // flag on `AdminField` carries this — no global column-name lookup.
2796
2797    #[test]
2798    fn encrypted_columns_are_redacted_in_admin_views() {
2799        let record = serde_json::json!({ "id": 1, "ssn": "123-45-6789" });
2800        let field = AdminField::new("ssn", AdminFieldKind::Text).encrypted();
2801        let cell = render_cell_value(&record, &field).into_string();
2802        let detail = render_detail_value(&record, &field).into_string();
2803        assert!(
2804            !cell.contains("123-45-6789"),
2805            "list cell must redact: {cell}"
2806        );
2807        assert!(
2808            !detail.contains("123-45-6789"),
2809            "detail must redact: {detail}"
2810        );
2811        assert!(cell.contains("••••••••"));
2812        assert!(detail.contains("••••••••"));
2813    }
2814
2815    // #1771: a confidential column is registered process-wide, so the admin can
2816    // mask it by name on every surface. Registered here directly rather than
2817    // through a `#[model]`, which would need a database schema this crate has no
2818    // reason to carry.
2819    autumn_web::reexports::inventory::submit! {
2820        autumn_web::confidential::ConfidentialColumnDescriptor {
2821            model: "AdminSealedNote",
2822            table: "admin_sealed_notes",
2823            column: "admin_sealed_body",
2824            blind_index: ::core::option::Option::Some("admin_sealed_body_bidx"),
2825        }
2826    }
2827
2828    /// The envelope and the token are masked in the list, the detail view and
2829    /// the editable control.
2830    #[test]
2831    fn confidential_columns_are_masked_across_admin_views() {
2832        let record = serde_json::json!({
2833            "id": 1,
2834            "admin_sealed_body": "z0BAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
2835            "admin_sealed_body_bidx": "0123456789abcdef0123456789abcdef",
2836        });
2837        for name in ["admin_sealed_body", "admin_sealed_body_bidx"] {
2838            let field = AdminField::new(name, AdminFieldKind::Text);
2839            let value = record.get(name).and_then(Value::as_str).unwrap();
2840            for (what, rendered) in [
2841                (
2842                    "list cell",
2843                    render_cell_value(&record, &field).into_string(),
2844                ),
2845                ("detail", render_detail_value(&record, &field).into_string()),
2846                (
2847                    "form widget",
2848                    render_form_widget(&field, Some(&record), true, &[]).into_string(),
2849                ),
2850            ] {
2851                assert!(
2852                    !rendered.contains(value),
2853                    "{what} leaked `{name}`: {rendered}"
2854                );
2855                assert!(rendered.contains("••••••••"), "{what}: {rendered}");
2856            }
2857        }
2858    }
2859
2860    /// A `create_only` column reaches `render_readonly_display` on EDIT instead
2861    /// of `render_form_widget`, so the mask has to live in the renderer.
2862    #[test]
2863    fn a_create_only_confidential_column_is_masked_on_the_edit_form() {
2864        let record = serde_json::json!({
2865            "id": 1,
2866            "admin_sealed_body": "z0BAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
2867        });
2868        let field = AdminField::new("admin_sealed_body", AdminFieldKind::Text);
2869        let rendered = render_readonly_display(&field, Some(&record)).into_string();
2870        assert!(
2871            !rendered.contains("z0BAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
2872            "the read-only display leaked the envelope: {rendered}"
2873        );
2874        assert!(rendered.contains("••••••••"), "{rendered}");
2875    }
2876
2877    #[test]
2878    fn admin_visible_encrypted_column_renders_plaintext_in_views() {
2879        // The decrypted record (admin loads it through the model) is shown for
2880        // an `admin_visible` column in list/detail views.
2881        let record = serde_json::json!({ "id": 1, "audit_note": "visible-note" });
2882        let field = AdminField::new("audit_note", AdminFieldKind::Text).encrypted_visible();
2883        let cell = render_cell_value(&record, &field).into_string();
2884        let detail = render_detail_value(&record, &field).into_string();
2885        assert!(cell.contains("visible-note"), "admin_visible cell: {cell}");
2886        assert!(
2887            detail.contains("visible-note"),
2888            "admin_visible detail: {detail}"
2889        );
2890    }
2891
2892    #[test]
2893    fn edit_form_never_prefills_encrypted_plaintext() {
2894        // Even an admin_visible column must not pre-fill its secret into the
2895        // editable form control.
2896        let record = serde_json::json!({ "ssn": "123-45-6789", "audit_note": "visible-note" });
2897        for field in [
2898            AdminField::new("ssn", AdminFieldKind::Text).encrypted(),
2899            AdminField::new("audit_note", AdminFieldKind::Text).encrypted_visible(),
2900        ] {
2901            let col = field.name;
2902            let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
2903            assert!(
2904                !form.contains("123-45-6789") && !form.contains("visible-note"),
2905                "edit form must not pre-fill encrypted plaintext for {col}: {form}"
2906            );
2907            // The edit control is disabled and carries no `name`, so it is not
2908            // submitted (and cannot overwrite the stored ciphertext).
2909            assert!(form.contains("disabled"), "edit control disabled: {form}");
2910            assert!(
2911                !form.contains("name="),
2912                "edit control must not submit: {form}"
2913            );
2914        }
2915    }
2916
2917    #[test]
2918    fn create_form_allows_setting_initial_encrypted_value() {
2919        // On CREATE (`record` is None) there is no stored secret to protect and the
2920        // New* DTO needs the value, so the encrypted field must be an editable,
2921        // submittable, empty input — otherwise the default "New" flow can't create
2922        // a record with a required encrypted column (#805).
2923        let field = AdminField::new("ssn", AdminFieldKind::Text).encrypted();
2924        let form = render_form_widget(&field, None, false, &[]).into_string();
2925        assert!(
2926            form.contains("name=\"ssn\""),
2927            "create control must submit the value: {form}"
2928        );
2929        assert!(
2930            !form.contains("disabled"),
2931            "create control editable: {form}"
2932        );
2933        assert!(
2934            !form.contains("••••••••"),
2935            "create control is an empty input, not the redaction mask: {form}"
2936        );
2937    }
2938
2939    #[test]
2940    fn create_failure_redisplay_never_echoes_encrypted_plaintext() {
2941        // Codex review, PR #2422: a create-validation-failure redisplay
2942        // passes `Some(record)` holding the admin's just-typed values
2943        // (`is_edit` stays `false`, so this isn't the EDIT-only redacted
2944        // branch) — an encrypted field must still never echo that plaintext
2945        // back into the response, in any widget kind, including `Json`
2946        // (which has its own record lookup, separate from the shared
2947        // `current_value` guard, for the #1341 stored-null case).
2948        let record = serde_json::json!({
2949            "ssn": "123-45-6789",
2950            "secret_config": {"token": "sk-live-999"},
2951        });
2952        for field in [
2953            AdminField::new("ssn", AdminFieldKind::Text).encrypted(),
2954            AdminField::new("secret_config", AdminFieldKind::Json).encrypted(),
2955        ] {
2956            let name = field.name;
2957            let form = render_form_widget(&field, Some(&record), false, &[]).into_string();
2958            assert!(
2959                form.contains(&format!("name=\"{name}\"")),
2960                "create control must stay submittable for {name}: {form}"
2961            );
2962            assert!(
2963                !form.contains("123-45-6789") && !form.contains("sk-live-999"),
2964                "encrypted {name} must not echo the submitted plaintext on a create failure: {form}"
2965            );
2966        }
2967    }
2968
2969    #[test]
2970    fn json_edit_form_prefills_a_stored_string_scalar_with_its_quotes() {
2971        // Issue #1341 review: a stored top-level JSON string like `"hello"`
2972        // must render WITH its quotes in the edit textarea. Without them, a
2973        // pure no-op resave either fails `coerce_form_value`'s JSON parse
2974        // (`hello` isn't valid JSON) or, worse, silently changes the value's
2975        // type (a stored `"true"`/`"42"` string would reparse as a bool/number).
2976        let field = AdminField::new("config", AdminFieldKind::Json);
2977
2978        let record = serde_json::json!({ "config": "hello" });
2979        let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
2980        assert!(
2981            form.contains("&quot;hello&quot;") || form.contains("\"hello\""),
2982            "stored JSON string must render with its quotes intact: {form}"
2983        );
2984
2985        // A string that looks like another JSON literal must round-trip as
2986        // the SAME string, not silently become that other type.
2987        let record = serde_json::json!({ "config": "true" });
2988        let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
2989        assert!(
2990            form.contains("&quot;true&quot;") || form.contains("\"true\""),
2991            "a stored JSON string \"true\" must not render as the bare word true: {form}"
2992        );
2993
2994        // Object/array values were already correct — no regression.
2995        let record = serde_json::json!({ "config": {"a": 1} });
2996        let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
2997        assert!(
2998            form.contains("{&quot;a&quot;:1}") || form.contains(r#"{"a":1}"#),
2999            "object values still render as JSON: {form}"
3000        );
3001
3002        // A NULL value on an OPTIONAL field renders as a blank textarea,
3003        // matching the existing "blank means no value" convention.
3004        let optional_field = AdminField::new("config", AdminFieldKind::Json).optional();
3005        let record = serde_json::json!({ "config": null });
3006        let form = render_form_widget(&optional_field, Some(&record), true, &[]).into_string();
3007        assert!(
3008            !form.contains("null"),
3009            "an optional NULL json value should render blank, not the literal word null: {form}"
3010        );
3011
3012        // A NULL value on a REQUIRED field is the legitimate JSON scalar
3013        // `null` (a NOT NULL JSONB column can still hold the JSON literal
3014        // `null` — it just can't be SQL NULL), not an absent value, and must
3015        // render as the literal text `null`. Rendering it blank would let the
3016        // browser's `required` attribute block saving any other field on the
3017        // record without the admin re-typing `null` by hand, and a
3018        // programmatic blank submission would coerce into `""` instead of
3019        // round-tripping back to `Value::Null` (Codex review finding on
3020        // #1341).
3021        let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
3022        assert!(
3023            form.contains(">null<") || form.contains("null"),
3024            "a required NULL json value must render the literal `null`, not blank: {form}"
3025        );
3026    }
3027
3028    #[test]
3029    fn json_create_form_starts_blank_even_when_required() {
3030        // Issue #1341 review follow-up: `record: None` means CREATE — there is
3031        // no stored value to prefill at all, which must NOT be conflated with
3032        // a required field's genuinely-stored `Value::Null` (tested above).
3033        // Both collapse to the same `current_value` if read through the
3034        // shared default, so the widget must distinguish "no record" from "a
3035        // null value in the record" directly.
3036        let field = AdminField::new("config", AdminFieldKind::Json);
3037        let form = render_form_widget(&field, None, false, &[]).into_string();
3038        assert!(
3039            !form.contains("null"),
3040            "a brand-new required json field on the CREATE form must start blank, \
3041             not prefilled with the literal word null: {form}"
3042        );
3043    }
3044
3045    fn list_result(page: u64, per_page: u64, total: u64) -> crate::traits::ListResult {
3046        crate::traits::ListResult {
3047            total,
3048            per_page,
3049            page,
3050            records: vec![],
3051        }
3052    }
3053
3054    #[test]
3055    fn render_pagination_shows_summary_and_nav() {
3056        let result = list_result(2, 10, 100);
3057        let html = render_pagination(
3058            &result,
3059            "users",
3060            "",
3061            None,
3062            crate::traits::SortDirection::Asc,
3063            "",
3064            "/admin",
3065        )
3066        .into_string();
3067        assert!(html.contains("Showing 11–20 of 100"), "{html}");
3068        assert!(html.contains("autumn-pager"), "{html}");
3069        // Page links target the model list path.
3070        assert!(html.contains("/admin/users?"), "{html}");
3071    }
3072
3073    #[test]
3074    fn render_pagination_preserves_search_and_sort() {
3075        let result = list_result(5, 10, 200); // 20 pages
3076        let html = render_pagination(
3077            &result,
3078            "users",
3079            "foo",
3080            Some("name"),
3081            crate::traits::SortDirection::Asc,
3082            "",
3083            "/admin",
3084        )
3085        .into_string();
3086        // Every emitted page link must keep the active filter and sort.
3087        for (i, _) in html.match_indices("href=\"") {
3088            let rest = &html[i + 6..];
3089            let end = rest.find('"').unwrap_or(rest.len());
3090            let href = &rest[..end];
3091            assert!(href.contains("q=foo"), "missing q in {href}");
3092            assert!(href.contains("sort=name"), "missing sort in {href}");
3093        }
3094        // Middle page of a 20-page set must window with an ellipsis.
3095        assert!(html.contains('…'), "{html}");
3096    }
3097
3098    #[test]
3099    fn render_pagination_marks_active_page() {
3100        let result = list_result(3, 10, 100);
3101        let html = render_pagination(
3102            &result,
3103            "users",
3104            "",
3105            None,
3106            crate::traits::SortDirection::Asc,
3107            "",
3108            "/admin",
3109        )
3110        .into_string();
3111        assert!(html.contains(r#"aria-current="page""#), "{html}");
3112    }
3113
3114    #[test]
3115    fn truncate_display_ascii() {
3116        assert_eq!(truncate_display("hello", 10), "hello");
3117        assert_eq!(truncate_display("hello world!", 6), "hello…");
3118    }
3119
3120    #[test]
3121    fn truncate_display_utf8_boundary_safe() {
3122        // 4 multi-byte chars, each 3 bytes. Byte slicing at 7 would panic.
3123        let s = "日本語日";
3124        assert_eq!(truncate_display(s, 3), "日本…");
3125        // Under the limit by char count, no truncation.
3126        assert_eq!(truncate_display(s, 10), s);
3127    }
3128
3129    #[test]
3130    fn url_encode_handles_reserved_chars() {
3131        assert_eq!(url_encode("hello world"), "hello%20world");
3132        assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
3133        assert_eq!(url_encode("safe-._~"), "safe-._~");
3134    }
3135
3136    #[test]
3137    fn url_encode_handles_utf8() {
3138        // "é" is 0xC3 0xA9 in UTF-8.
3139        assert_eq!(url_encode("é"), "%C3%A9");
3140    }
3141
3142    #[test]
3143    fn normalize_datetime_local_accepts_expected_shape() {
3144        assert_eq!(
3145            normalize_datetime_local_input("2026-04-24T12:34"),
3146            "2026-04-24T12:34"
3147        );
3148    }
3149
3150    #[test]
3151    fn normalize_datetime_local_strips_seconds() {
3152        assert_eq!(
3153            normalize_datetime_local_input("2026-04-24T12:34:56"),
3154            "2026-04-24T12:34"
3155        );
3156    }
3157
3158    #[test]
3159    fn normalize_datetime_local_strips_rfc3339_zulu() {
3160        // The browser refuses the `Z` suffix; we emit UTC without offset.
3161        assert_eq!(
3162            normalize_datetime_local_input("2026-04-24T12:34:56Z"),
3163            "2026-04-24T12:34"
3164        );
3165    }
3166
3167    #[test]
3168    fn normalize_datetime_local_preserves_wall_time_across_offsets() {
3169        // Regression: previously this used naive_utc() which shifted
3170        // 12:34+05:30 to 07:04 UTC. That mutated the value on a no-op
3171        // edit-save round trip. Now we preserve the local wall clock —
3172        // the offset is dropped, but 12:34 stays 12:34 so re-saving
3173        // produces the same logical timestamp.
3174        assert_eq!(
3175            normalize_datetime_local_input("2026-04-24T12:34:56+05:30"),
3176            "2026-04-24T12:34"
3177        );
3178        // Negative offset, end-of-day boundary — verify the date doesn't
3179        // flip either.
3180        assert_eq!(
3181            normalize_datetime_local_input("2026-04-24T23:30:00-04:00"),
3182            "2026-04-24T23:30"
3183        );
3184    }
3185
3186    #[test]
3187    fn normalize_datetime_local_empty_stays_empty() {
3188        assert_eq!(normalize_datetime_local_input(""), "");
3189    }
3190
3191    #[test]
3192    fn normalize_datetime_local_leaves_garbage_untouched() {
3193        // Better to show the raw value than silently blank the field.
3194        assert_eq!(normalize_datetime_local_input("not-a-date"), "not-a-date");
3195    }
3196
3197    #[test]
3198    fn normalize_date_accepts_expected_shape() {
3199        assert_eq!(normalize_date_input("2026-04-24"), "2026-04-24");
3200    }
3201
3202    #[test]
3203    fn normalize_date_extracts_from_rfc3339() {
3204        assert_eq!(normalize_date_input("2026-04-24T12:34:56Z"), "2026-04-24");
3205    }
3206
3207    // ── End-to-end render checks (CSRF / XSS / actuator prefix wiring) ──
3208
3209    fn dummy_registry() -> AdminRegistry {
3210        AdminRegistry::new()
3211    }
3212
3213    #[test]
3214    fn history_page_pagination_preserves_per_page() {
3215        let r = dummy_registry();
3216        let history = AdminHistoryPage {
3217            entries: vec![crate::traits::AdminHistoryEntry {
3218                id: 1,
3219                actor: "system".to_owned(),
3220                op: "insert".to_owned(),
3221                request_id: None,
3222                changes: vec![],
3223                recorded_at: chrono::Utc::now(),
3224            }],
3225            total: 250,
3226            page: 2,
3227            per_page: 100,
3228        };
3229
3230        let html = model_history_page(
3231            &r,
3232            "posts",
3233            "Post",
3234            "Posts",
3235            42,
3236            &history,
3237            "/admin",
3238            "/ops",
3239            "X-CSRF-Token",
3240            false,
3241            None,
3242        )
3243        .into_string();
3244
3245        assert!(
3246            html.contains("/admin/posts/42/history?page=1&amp;per_page=100"),
3247            "previous history page link must preserve per_page: {html}"
3248        );
3249        assert!(
3250            html.contains("/admin/posts/42/history?page=3&amp;per_page=100"),
3251            "next history page link must preserve per_page: {html}"
3252        );
3253    }
3254
3255    #[test]
3256    fn dashboard_emits_csrf_meta_and_script() {
3257        let r = dummy_registry();
3258        let html = dashboard_page(
3259            &r,
3260            &[],
3261            &[],
3262            "tok-123",
3263            "X-CSRF-Token",
3264            "/admin",
3265            "/ops",
3266            false,
3267            None,
3268        )
3269        .into_string();
3270        assert!(
3271            html.contains(r#"<meta name="csrf-token" content="tok-123""#),
3272            "CSRF meta tag missing: {html}"
3273        );
3274        // Loaded through its content-hashed URL (`autumn-htmx-csrf.<hash>.js`).
3275        let csrf_src = format!(
3276            r#"src="{}""#,
3277            autumn_web::assets::asset_url("js/autumn-htmx-csrf.js")
3278        );
3279        assert!(
3280            html.contains(&csrf_src),
3281            "HTMX CSRF helper script not loaded: {html}"
3282        );
3283    }
3284
3285    #[test]
3286    fn dashboard_uses_configured_actuator_prefix() {
3287        let r = dummy_registry();
3288        let html = dashboard_page(
3289            &r,
3290            &[],
3291            &[],
3292            "tok",
3293            "X-CSRF-Token",
3294            "/admin",
3295            "/ops",
3296            false,
3297            None,
3298        )
3299        .into_string();
3300        assert!(
3301            html.contains(r#"href="/ops/ui""#),
3302            "sidebar link wrong: {html}"
3303        );
3304        assert!(
3305            html.contains(r#"hx-get="/ops/ui/metrics""#),
3306            "metrics polling URL wrong: {html}"
3307        );
3308        assert!(
3309            !html.contains("/actuator/"),
3310            "must not hardcode /actuator when prefix is /ops: {html}"
3311        );
3312    }
3313
3314    #[test]
3315    #[allow(clippy::too_many_lines)]
3316    fn jobs_page_renders_lists_actions_polling_and_csrf() {
3317        use autumn_web::job::{
3318            JobAdminPage, JobAdminRecord, JobAdminSnapshot, JobAdminStatus, JobScheduleSummary,
3319        };
3320
3321        let r = dummy_registry();
3322        let snapshot = JobAdminSnapshot {
3323            enqueued: JobAdminPage::new(
3324                vec![JobAdminRecord {
3325                    id: "job-enqueued".to_owned(),
3326                    name: "send_email".to_owned(),
3327                    queue: "default".to_owned(),
3328                    status: JobAdminStatus::Enqueued,
3329                    enqueued_at: Some("2026-05-07T10:00:00Z".to_owned()),
3330                    scheduled_for: None,
3331                    started_at: None,
3332                    finished_at: None,
3333                    attempt: 1,
3334                    max_attempts: 5,
3335                    last_error: None,
3336                    principal_id: Some("42".to_owned()),
3337                    correlation_id: Some("req-123".to_owned()),
3338                    blocked_on_concurrency: false,
3339                }],
3340                1,
3341                1,
3342                25,
3343            ),
3344            scheduled: JobAdminPage::new(
3345                vec![JobAdminRecord {
3346                    id: "job-scheduled".to_owned(),
3347                    name: "reminder".to_owned(),
3348                    queue: "default".to_owned(),
3349                    status: JobAdminStatus::Scheduled,
3350                    enqueued_at: Some("2026-05-07T10:00:00Z".to_owned()),
3351                    scheduled_for: Some("2026-05-08T10:00:00Z".to_owned()),
3352                    started_at: None,
3353                    finished_at: None,
3354                    attempt: 1,
3355                    max_attempts: 5,
3356                    last_error: None,
3357                    principal_id: None,
3358                    correlation_id: None,
3359                    blocked_on_concurrency: false,
3360                }],
3361                1,
3362                1,
3363                25,
3364            ),
3365            running: JobAdminPage::new(
3366                vec![JobAdminRecord {
3367                    id: "job-running".to_owned(),
3368                    name: "reindex".to_owned(),
3369                    queue: "default".to_owned(),
3370                    status: JobAdminStatus::Running,
3371                    enqueued_at: Some("2026-05-07T10:01:00Z".to_owned()),
3372                    scheduled_for: None,
3373                    started_at: Some("2026-05-07T10:02:00Z".to_owned()),
3374                    finished_at: None,
3375                    attempt: 1,
3376                    max_attempts: 3,
3377                    last_error: None,
3378                    principal_id: None,
3379                    correlation_id: None,
3380                    blocked_on_concurrency: false,
3381                }],
3382                1,
3383                1,
3384                25,
3385            ),
3386            completed: JobAdminPage::new(
3387                vec![JobAdminRecord {
3388                    id: "job-complete".to_owned(),
3389                    name: "digest".to_owned(),
3390                    queue: "default".to_owned(),
3391                    status: JobAdminStatus::Completed,
3392                    enqueued_at: Some("2026-05-07T09:00:00Z".to_owned()),
3393                    scheduled_for: None,
3394                    started_at: Some("2026-05-07T09:01:00Z".to_owned()),
3395                    finished_at: Some("2026-05-07T09:02:00Z".to_owned()),
3396                    attempt: 1,
3397                    max_attempts: 3,
3398                    last_error: None,
3399                    principal_id: None,
3400                    correlation_id: None,
3401                    blocked_on_concurrency: false,
3402                }],
3403                1,
3404                1,
3405                25,
3406            ),
3407            failed: JobAdminPage::new(
3408                vec![JobAdminRecord {
3409                    id: "job-failed".to_owned(),
3410                    name: "send_email".to_owned(),
3411                    queue: "default".to_owned(),
3412                    status: JobAdminStatus::Failed,
3413                    enqueued_at: Some("2026-05-07T08:00:00Z".to_owned()),
3414                    scheduled_for: None,
3415                    started_at: Some("2026-05-07T08:01:00Z".to_owned()),
3416                    finished_at: Some("2026-05-07T08:02:00Z".to_owned()),
3417                    attempt: 5,
3418                    max_attempts: 5,
3419                    last_error: Some("smtp refused recipient".repeat(6)),
3420                    principal_id: Some("7".to_owned()),
3421                    correlation_id: None,
3422                    blocked_on_concurrency: false,
3423                }],
3424                1,
3425                1,
3426                25,
3427            ),
3428            schedules: vec![JobScheduleSummary {
3429                name: "send-digest".to_owned(),
3430                schedule: "every 1h".to_owned(),
3431                next_run_at: None,
3432                last_run_status: Some("ok".to_owned()),
3433            }],
3434            bounded_history_limit: 1_000,
3435        };
3436
3437        let html = jobs_page(
3438            &r,
3439            &snapshot,
3440            &[],
3441            "tok-job",
3442            "authenticity_token",
3443            "X-CSRF-Token",
3444            "/admin",
3445            "/actuator",
3446            false,
3447            None,
3448        )
3449        .into_string();
3450        assert!(html.contains("Jobs"));
3451        assert!(html.contains("Enqueued"));
3452        assert!(html.contains("Running"));
3453        assert!(html.contains("Completed (last 24h)"));
3454        assert!(html.contains("Failed (last 7d)"));
3455        assert!(html.contains("send_email"));
3456        assert!(html.contains("req-123"));
3457        assert!(html.contains(r#"action="/admin/jobs/job-failed/retry""#));
3458        assert!(html.contains(r#"action="/admin/jobs/job-failed/discard""#));
3459        assert!(html.contains(r#"action="/admin/jobs/job-enqueued/cancel""#));
3460        // Scheduled (delayed) jobs render their own list, show the due time, and
3461        // can be canceled before they run.
3462        assert!(html.contains("Scheduled"));
3463        assert!(html.contains("due 2026-05-08T10:00:00Z"));
3464        assert!(html.contains(r#"action="/admin/jobs/job-scheduled/cancel""#));
3465        assert!(html.contains(r#"name="authenticity_token" value="tok-job""#));
3466        assert!(!html.contains(r#"name="_csrf" value="tok-job""#));
3467        assert!(html.contains(r#"hx-get="/admin/jobs/counters""#));
3468        assert!(html.contains(r#"hx-trigger="load, every 2s""#));
3469        assert!(html.contains("send-digest"));
3470    }
3471
3472    /// The parked-row marker is small foreground text on `--surface`, so it
3473    /// must use the text-safe token, not raw `--warning` (3.19:1, below
3474    /// WCAG AA). The rule lives in `ADMIN_CSS`, so assert the rule body —
3475    /// `job_row_flags_a_concurrency_parked_job` only sees the phrase.
3476    #[test]
3477    fn job_blocked_marker_uses_the_text_safe_warning_token() {
3478        let start = ADMIN_CSS
3479            .find(".job-blocked")
3480            .expect("missing `.job-blocked` rule in ADMIN_CSS");
3481        let block_end = ADMIN_CSS[start..]
3482            .find('}')
3483            .map_or(ADMIN_CSS.len(), |i| start + i);
3484        let block = &ADMIN_CSS[start..block_end];
3485        assert!(
3486            block.contains("color: var(--warning-text)"),
3487            "`.job-blocked` must use --warning-text: {block}"
3488        );
3489        assert!(
3490            !block.contains("var(--warning)"),
3491            "raw --warning fails WCAG AA as normal text on --surface: {block}"
3492        );
3493    }
3494
3495    /// #1186: the Redis enqueued tab lists concurrency-parked jobs, so a row
3496    /// must say whether it is waiting on a slot or ready to claim.
3497    #[test]
3498    fn job_row_flags_a_concurrency_parked_job() {
3499        use autumn_web::job::{JobAdminRecord, JobAdminStatus};
3500
3501        let mut record = JobAdminRecord {
3502            id: "job-parked".to_owned(),
3503            name: "recalculate".to_owned(),
3504            queue: "default".to_owned(),
3505            status: JobAdminStatus::Enqueued,
3506            enqueued_at: Some("2026-05-07T10:00:00Z".to_owned()),
3507            scheduled_for: None,
3508            started_at: None,
3509            finished_at: None,
3510            attempt: 1,
3511            max_attempts: 5,
3512            last_error: None,
3513            principal_id: None,
3514            correlation_id: None,
3515            blocked_on_concurrency: true,
3516        };
3517
3518        let parked = job_row(&record, "tok", "authenticity_token", "/admin").into_string();
3519        assert!(
3520            parked.contains("waiting on a concurrency slot"),
3521            "parked row must be annotated: {parked}"
3522        );
3523        // A parked job has not started, so the operator can still cancel it.
3524        assert!(
3525            parked.contains(r#"action="/admin/jobs/job-parked/cancel""#),
3526            "parked row must keep its Cancel action: {parked}"
3527        );
3528
3529        record.blocked_on_concurrency = false;
3530        let ready = job_row(&record, "tok", "authenticity_token", "/admin").into_string();
3531        assert!(
3532            !ready.contains("waiting on a concurrency slot"),
3533            "a ready row must not be annotated: {ready}"
3534        );
3535    }
3536
3537    #[test]
3538    fn jobs_counters_fragment_preserves_polling_after_outer_swap() {
3539        use autumn_web::job::JobAdminSnapshot;
3540
3541        let html = jobs_counters(&JobAdminSnapshot::empty(), "/admin").into_string();
3542        assert!(html.contains(r#"id="jobs-counters""#));
3543        assert!(html.contains(r#"hx-get="/admin/jobs/counters""#));
3544        assert!(html.contains(r#"hx-trigger="load, every 2s""#));
3545        assert!(html.contains(r#"hx-swap="outerHTML""#));
3546    }
3547
3548    #[test]
3549    fn form_page_renders_hidden_csrf_input() {
3550        let r = dummy_registry();
3551        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3552        let html = model_form_page(
3553            &r,
3554            "widgets",
3555            "Widget",
3556            "Widgets",
3557            &fields,
3558            None,
3559            None,
3560            &[],
3561            &[],
3562            "tok-xyz",
3563            "authenticity_token",
3564            "X-CSRF-Token",
3565            "/admin",
3566            "/actuator",
3567            false,
3568            None,
3569        )
3570        .into_string();
3571        assert!(
3572            html.contains(r#"<input type="hidden" name="authenticity_token" value="tok-xyz""#),
3573            "custom CSRF hidden field missing: {html}"
3574        );
3575        assert!(!html.contains(r#"name="_csrf" value="tok-xyz""#));
3576    }
3577
3578    #[test]
3579    fn form_page_normalizes_datetime_for_browser_input() {
3580        let r = dummy_registry();
3581        let fields = vec![AdminField::new("created_at", AdminFieldKind::DateTime)];
3582        // RFC 3339 with `Z` — would render as empty without normalization.
3583        let record = serde_json::json!({"id": 1, "created_at": "2026-04-24T12:34:56Z"});
3584        let html = model_form_page(
3585            &r,
3586            "widgets",
3587            "Widget",
3588            "Widgets",
3589            &fields,
3590            Some(&record),
3591            Some(1),
3592            &[],
3593            &[],
3594            "t",
3595            "_csrf",
3596            "X-CSRF-Token",
3597            "/admin",
3598            "/actuator",
3599            false,
3600            None,
3601        )
3602        .into_string();
3603        assert!(
3604            html.contains(r#"value="2026-04-24T12:34""#),
3605            "datetime-local input should carry browser-friendly value: {html}"
3606        );
3607        assert!(
3608            !html.contains(r#"value="2026-04-24T12:34:56Z""#),
3609            "raw RFC3339 must not reach datetime-local input: {html}"
3610        );
3611    }
3612
3613    #[test]
3614    fn form_page_action_uses_path_id_not_payload_id() {
3615        // Regression: mutation target must come from the URL path, not the
3616        // record payload. Payload says id=99, path says 42 — form posts to 42.
3617        let r = dummy_registry();
3618        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3619        let record = serde_json::json!({"id": 99, "name": "x"});
3620        let html = model_form_page(
3621            &r,
3622            "widgets",
3623            "Widget",
3624            "Widgets",
3625            &fields,
3626            Some(&record),
3627            Some(42),
3628            &[],
3629            &[],
3630            "t",
3631            "_csrf",
3632            "X-CSRF-Token",
3633            "/admin",
3634            "/actuator",
3635            false,
3636            None,
3637        )
3638        .into_string();
3639        assert!(
3640            html.contains(r#"action="/admin/widgets/42""#),
3641            "form action should use path-based id 42, not payload id 99: {html}"
3642        );
3643        assert!(
3644            !html.contains(r#"action="/admin/widgets/99""#),
3645            "payload-derived id must not appear in form action: {html}"
3646        );
3647    }
3648
3649    #[test]
3650    fn detail_page_edit_delete_links_use_path_id() {
3651        let r = dummy_registry();
3652        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3653        let record = serde_json::json!({"id": 99, "name": "x"});
3654        let html = model_detail_page(
3655            &r,
3656            "widgets",
3657            "Widget",
3658            "Widgets",
3659            &fields,
3660            &record,
3661            "#42",
3662            42,
3663            &[],
3664            "t",
3665            "_csrf",
3666            "X-CSRF-Token",
3667            "/admin",
3668            "/actuator",
3669            false,
3670            false,
3671            None,
3672        )
3673        .into_string();
3674        assert!(
3675            html.contains(r#"href="/admin/widgets/42/edit""#),
3676            "Edit link must use path id 42: {html}"
3677        );
3678        assert!(
3679            html.contains(r#"<form action="/admin/widgets/42" method="post""#),
3680            "Delete confirm form must target path id 42: {html}"
3681        );
3682        assert!(
3683            !html.contains("widgets/99"),
3684            "payload id 99 must not route mutations: {html}"
3685        );
3686        // The delete button is now rendered via confirm_action (a server-rendered
3687        // <dialog>), so a no-JS form submission also works: POST + _method=DELETE + CSRF.
3688        assert!(
3689            html.contains(r#"name="_method" value="DELETE""#),
3690            "Delete form must carry a _method override: {html}"
3691        );
3692        assert!(
3693            html.contains(r#"name="_csrf" value="t""#),
3694            "Delete form must carry the CSRF token: {html}"
3695        );
3696    }
3697
3698    #[test]
3699    fn detail_page_delete_uses_confirm_dialog_not_hx_confirm() {
3700        // Issue #1233: the admin delete confirmation must be a server-rendered,
3701        // testable <dialog> (autumn_web::widgets::confirm_action), not the
3702        // native window.confirm() reached via hx-confirm.
3703        let r = dummy_registry();
3704        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3705        let record = serde_json::json!({"id": 42, "name": "x"});
3706        let html = model_detail_page(
3707            &r,
3708            "widgets",
3709            "Widget",
3710            "Widgets",
3711            &fields,
3712            &record,
3713            "#42",
3714            42,
3715            &[],
3716            "t",
3717            "_csrf",
3718            "X-CSRF-Token",
3719            "/admin",
3720            "/actuator",
3721            false,
3722            false,
3723            None,
3724        )
3725        .into_string();
3726        assert!(!html.contains("hx-confirm"), "{html}");
3727        assert!(!html.contains("hx-delete"), "{html}");
3728        assert!(html.contains("<dialog"), "{html}");
3729        assert!(html.contains(r#"aria-modal="true""#), "{html}");
3730        assert!(
3731            html.contains("Widget"),
3732            "confirm dialog title should mention the model name: {html}"
3733        );
3734        // The trigger button opens the dialog via the native invoker
3735        // commands (with a JS-fallback data attribute) rather than hx-delete.
3736        assert!(html.contains(r#"command="show-modal""#), "{html}");
3737        assert!(html.contains("data-modal-open"), "{html}");
3738    }
3739
3740    #[test]
3741    fn detail_page_delete_button_honors_custom_csrf_form_field() {
3742        // Regression: the delete button must emit the CSRF hidden input
3743        // under the app's configured field name, not a hardcoded "_csrf" —
3744        // otherwise a no-JS form submission fails CSRF validation whenever
3745        // security.csrf.form_field is customized.
3746        let r = dummy_registry();
3747        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3748        let record = serde_json::json!({"id": 42, "name": "x"});
3749        let html = model_detail_page(
3750            &r,
3751            "widgets",
3752            "Widget",
3753            "Widgets",
3754            &fields,
3755            &record,
3756            "#42",
3757            42,
3758            &[],
3759            "t",
3760            "authenticity_token",
3761            "X-CSRF-Token",
3762            "/admin",
3763            "/actuator",
3764            false,
3765            false,
3766            None,
3767        )
3768        .into_string();
3769        assert!(
3770            html.contains(r#"name="authenticity_token" value="t""#),
3771            "Delete form must use the configured CSRF field name: {html}"
3772        );
3773        assert!(
3774            !html.contains(r#"name="_csrf""#),
3775            "Delete form must not fall back to the default CSRF field name: {html}"
3776        );
3777    }
3778
3779    #[test]
3780    fn detail_view_escapes_malicious_json() {
3781        let r = dummy_registry();
3782        let fields = vec![AdminField::new("meta", AdminFieldKind::Json)];
3783        // Pretty-printed JSON of a nested object contains attacker-controlled
3784        // angle brackets. Pre-fix this rendered as live HTML.
3785        let record = serde_json::json!({
3786            "id": 1,
3787            "meta": {"xss": "<script>alert(1)</script>"},
3788        });
3789        let html = model_detail_page(
3790            &r,
3791            "widgets",
3792            "Widget",
3793            "Widgets",
3794            &fields,
3795            &record,
3796            "#1",
3797            1,
3798            &[],
3799            "t",
3800            "_csrf",
3801            "X-CSRF-Token",
3802            "/admin",
3803            "/actuator",
3804            false,
3805            false,
3806            None,
3807        )
3808        .into_string();
3809        assert!(
3810            !html.contains("<script>alert(1)</script>"),
3811            "raw <script> must be escaped: {html}"
3812        );
3813        assert!(
3814            html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"),
3815            "escaped form expected: {html}"
3816        );
3817    }
3818
3819    #[test]
3820    fn layout_loads_external_admin_js_not_inline() {
3821        // The layout must NOT ship an inline <script>{js}</script> block —
3822        // that would be blocked by the default CSP (`script-src 'self'`).
3823        // Instead it must load the plugin-owned asset at its fingerprinted
3824        // `/static/_plugins/autumn-admin/admin.<hash>.js` URL.
3825        let r = dummy_registry();
3826        let html = dashboard_page(
3827            &r,
3828            &[],
3829            &[],
3830            "t",
3831            "X-CSRF-Token",
3832            "/admin",
3833            "/actuator",
3834            false,
3835            None,
3836        )
3837        .into_string();
3838        let asset = crate::routes::ASSETS
3839            .get("admin.js")
3840            .expect("admin.js is in the bundle");
3841        let expected = format!(r#"src="{}""#, asset.url());
3842        assert!(
3843            html.contains(&expected),
3844            "admin.js must be referenced as an external script at {expected}: {html}"
3845        );
3846        // The URL must be content-fingerprinted so immutable caching is safe,
3847        // and carry SRI so a stale or tampered copy is refused.
3848        assert!(
3849            asset
3850                .url()
3851                .starts_with("/static/_plugins/autumn-admin/admin.")
3852                && asset.url().rsplit('.').next() == Some("js")
3853                && asset.url() != asset.plain_url(),
3854            "admin.js URL should be fingerprinted (admin.<hash>.js): {}",
3855            asset.url()
3856        );
3857        assert!(
3858            html.contains(&format!(r#"integrity="{}""#, asset.integrity())),
3859            "admin.js tag should carry its SRI hash: {html}"
3860        );
3861        assert!(
3862            !html.contains(r#"src="/static/_plugins/autumn-admin/admin.js""#),
3863            "unfingerprinted URL would invalidate immutable caching: {html}"
3864        );
3865        // No inline onclick on the select-all checkbox either — it's
3866        // wired via event delegation in admin.js now.
3867        assert!(
3868            !html.contains("onclick=\""),
3869            "no inline event handlers allowed under default CSP: {html}"
3870        );
3871    }
3872
3873    #[test]
3874    fn list_page_hides_hidden_fields_even_if_list_display_true() {
3875        use crate::traits::ListResult;
3876        let r = dummy_registry();
3877        // Hidden contract: shown in detail, not in list. Even with
3878        // list_display=true (the default), the column must not appear.
3879        let fields = vec![
3880            AdminField::new("name", AdminFieldKind::Text),
3881            AdminField::new("internal_token", AdminFieldKind::Hidden),
3882        ];
3883        let result = ListResult {
3884            records: vec![serde_json::json!({
3885                "id": 1,
3886                "name": "alice",
3887                "internal_token": "INT-9999",
3888            })],
3889            total: 1,
3890            page: 1,
3891            per_page: 25,
3892        };
3893        let html = model_list_page(
3894            &r,
3895            "users",
3896            "Users",
3897            &fields,
3898            &[],
3899            &result,
3900            "",
3901            None,
3902            SortDirection::Asc,
3903            &[],
3904            &[],
3905            "t",
3906            "_csrf",
3907            "X-CSRF-Token",
3908            "/admin",
3909            "/actuator",
3910            false,
3911            false,
3912            false,
3913            None,
3914        )
3915        .into_string();
3916        assert!(
3917            !html.contains("INT-9999"),
3918            "hidden field value must not surface in list view: {html}"
3919        );
3920        assert!(
3921            !html.contains("internal_token") && !html.contains("Internal Token"),
3922            "hidden field column header must not appear: {html}"
3923        );
3924    }
3925
3926    #[test]
3927    fn list_page_hides_password_fields_even_if_list_display_true() {
3928        use crate::traits::ListResult;
3929        let r = dummy_registry();
3930        // A model that (incorrectly) marks a password field list_display=true.
3931        // The admin plugin must drop it from the index table anyway.
3932        let fields = vec![
3933            AdminField::new("name", AdminFieldKind::Text),
3934            AdminField::new("password_hash", AdminFieldKind::Password),
3935        ];
3936        let result = ListResult {
3937            records: vec![serde_json::json!({
3938                "id": 1,
3939                "name": "alice",
3940                "password_hash": "$argon2id$leaked",
3941            })],
3942            total: 1,
3943            page: 1,
3944            per_page: 25,
3945        };
3946        let html = model_list_page(
3947            &r,
3948            "users",
3949            "Users",
3950            &fields,
3951            &[],
3952            &result,
3953            "",
3954            None,
3955            SortDirection::Asc,
3956            &[],
3957            &[],
3958            "t",
3959            "_csrf",
3960            "X-CSRF-Token",
3961            "/admin",
3962            "/actuator",
3963            false,
3964            false,
3965            false,
3966            None,
3967        )
3968        .into_string();
3969        assert!(
3970            !html.contains("$argon2id$leaked"),
3971            "raw password hash must not appear in list view: {html}"
3972        );
3973        assert!(
3974            !html.contains("password_hash"),
3975            "password column must not have a header in list view: {html}"
3976        );
3977    }
3978
3979    #[test]
3980    fn list_page_handles_records_without_numeric_id() {
3981        // A model that returns a row whose `id` is missing (or non-numeric)
3982        // must not render `/admin/widgets/0` action links — those would
3983        // route mutations to the wrong row. Show "no id" instead.
3984        use crate::traits::ListResult;
3985        let r = dummy_registry();
3986        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3987        let result = ListResult {
3988            records: vec![
3989                serde_json::json!({"id": 7, "name": "with id"}),
3990                serde_json::json!({"name": "no id"}),
3991            ],
3992            total: 2,
3993            page: 1,
3994            per_page: 25,
3995        };
3996        let html = model_list_page(
3997            &r,
3998            "widgets",
3999            "Widgets",
4000            &fields,
4001            &[],
4002            &result,
4003            "",
4004            None,
4005            SortDirection::Asc,
4006            &[],
4007            &[],
4008            "t",
4009            "_csrf",
4010            "X-CSRF-Token",
4011            "/admin",
4012            "/actuator",
4013            false,
4014            false,
4015            false,
4016            None,
4017        )
4018        .into_string();
4019        // Row with id renders working links and a checkbox.
4020        assert!(
4021            html.contains(r#"href="/admin/widgets/7""#),
4022            "row with id should have working View link: {html}"
4023        );
4024        // Row without id shows the placeholder, never `/0` links.
4025        assert!(
4026            html.contains(r#"<span style="color: var(--text-muted); font-size: 0.75rem;">no id"#)
4027                || html.contains("no id</span>"),
4028            "row without id should show 'no id' placeholder: {html}"
4029        );
4030        assert!(
4031            !html.contains("/admin/widgets/0"),
4032            "must not generate /0 links for rows missing id: {html}"
4033        );
4034    }
4035
4036    #[test]
4037    fn list_page_carries_filters_into_sort_and_pagination_links() {
4038        // Active filter must round-trip through every navigation URL the
4039        // list view generates — sort header links AND pagination links.
4040        // Otherwise a user with `?filter.status=active` who clicks a
4041        // column header silently reverts to unfiltered results.
4042        use crate::traits::ListResult;
4043        let r = dummy_registry();
4044        let mut name = AdminField::new("name", AdminFieldKind::Text);
4045        name.sortable = true;
4046        let fields = vec![name];
4047        // 60 records over per_page=25 → 3 pages, so pagination renders.
4048        let result = ListResult {
4049            records: vec![serde_json::json!({"id": 1, "name": "alice"})],
4050            total: 60,
4051            page: 1,
4052            per_page: 25,
4053        };
4054        let active_filters = vec![
4055            ("status".to_owned(), "active".to_owned()),
4056            ("tier".to_owned(), "premium".to_owned()),
4057        ];
4058        let html = model_list_page(
4059            &r,
4060            "users",
4061            "Users",
4062            &fields,
4063            &[],
4064            &result,
4065            "",
4066            None,
4067            SortDirection::Asc,
4068            &active_filters,
4069            &[],
4070            "t",
4071            "_csrf",
4072            "X-CSRF-Token",
4073            "/admin",
4074            "/actuator",
4075            false,
4076            false,
4077            false,
4078            None,
4079        )
4080        .into_string();
4081        // Sort header link carries both filters.
4082        assert!(
4083            html.contains("filter.status=active"),
4084            "sort link must preserve filter.status: {html}"
4085        );
4086        assert!(
4087            html.contains("filter.tier=premium"),
4088            "sort link must preserve filter.tier: {html}"
4089        );
4090        // Pagination link to page 2 carries both filters too.
4091        assert!(
4092            html.contains("page=2") && html.contains("filter.status=active"),
4093            "pagination link must preserve filter.status: {html}"
4094        );
4095    }
4096
4097    #[test]
4098    fn search_form_carries_filters_as_hidden_inputs() {
4099        // Regression: search submit (method=get) drops anything not in
4100        // the form. Active filters must round-trip via hidden inputs so
4101        // typing a search query doesn't reset the dataset.
4102        use crate::traits::ListResult;
4103        let r = dummy_registry();
4104        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4105        let result = ListResult {
4106            records: vec![],
4107            total: 0,
4108            page: 1,
4109            per_page: 25,
4110        };
4111        let active_filters = vec![
4112            ("status".to_owned(), "active".to_owned()),
4113            ("tier".to_owned(), "premium".to_owned()),
4114        ];
4115        let html = model_list_page(
4116            &r,
4117            "users",
4118            "Users",
4119            &fields,
4120            &[],
4121            &result,
4122            "",
4123            None,
4124            SortDirection::Asc,
4125            &active_filters,
4126            &[],
4127            "t",
4128            "_csrf",
4129            "X-CSRF-Token",
4130            "/admin",
4131            "/actuator",
4132            false,
4133            false,
4134            false,
4135            None,
4136        )
4137        .into_string();
4138        assert!(
4139            html.contains(r#"<input type="hidden" name="filter.status" value="active""#),
4140            "search form should preserve filter.status: {html}"
4141        );
4142        assert!(
4143            html.contains(r#"<input type="hidden" name="filter.tier" value="premium""#),
4144            "search form should preserve filter.tier: {html}"
4145        );
4146        // Live-search must include the hidden filter inputs in the
4147        // HTMX request, otherwise typing in the search box silently
4148        // resets to unfiltered results. `hx-include="closest form"`
4149        // pulls every form input (including the filter hiddens) into
4150        // the request, matching the full-form GET-submit behaviour.
4151        assert!(
4152            html.contains(r#"hx-include="closest form""#),
4153            "search input must hx-include the form so live-search carries filters: {html}"
4154        );
4155    }
4156
4157    #[test]
4158    fn list_page_url_encodes_filter_values() {
4159        // Filter values containing reserved chars must be percent-encoded
4160        // so they round-trip through the URL parser cleanly.
4161        use crate::traits::ListResult;
4162        let r = dummy_registry();
4163        let mut name = AdminField::new("name", AdminFieldKind::Text);
4164        name.sortable = true;
4165        let fields = vec![name];
4166        let result = ListResult {
4167            records: vec![],
4168            total: 0,
4169            page: 1,
4170            per_page: 25,
4171        };
4172        let active_filters = vec![("q".to_owned(), "a&b=c".to_owned())];
4173        let html = model_list_page(
4174            &r,
4175            "users",
4176            "Users",
4177            &fields,
4178            &[],
4179            &result,
4180            "",
4181            None,
4182            SortDirection::Asc,
4183            &active_filters,
4184            &[],
4185            "t",
4186            "_csrf",
4187            "X-CSRF-Token",
4188            "/admin",
4189            "/actuator",
4190            false,
4191            false,
4192            false,
4193            None,
4194        )
4195        .into_string();
4196        assert!(
4197            html.contains("filter.q=a%26b%3Dc"),
4198            "filter values must be percent-encoded in generated links: {html}"
4199        );
4200    }
4201
4202    #[test]
4203    fn list_page_renders_bulk_action_form() {
4204        use crate::traits::{ActionStyle, ListResult};
4205        let r = dummy_registry();
4206        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4207        let actions = vec![
4208            AdminAction {
4209                name: "delete",
4210                label: "Delete selected".to_owned(),
4211                style: ActionStyle::Danger,
4212                confirm: true,
4213            },
4214            AdminAction {
4215                name: "archive",
4216                label: "Archive".to_owned(),
4217                style: ActionStyle::Default,
4218                confirm: false,
4219            },
4220        ];
4221        let result = ListResult {
4222            records: vec![serde_json::json!({"id": 1, "name": "x"})],
4223            total: 1,
4224            page: 1,
4225            per_page: 25,
4226        };
4227        let html = model_list_page(
4228            &r,
4229            "widgets",
4230            "Widgets",
4231            &fields,
4232            &actions,
4233            &result,
4234            "",
4235            None,
4236            SortDirection::Asc,
4237            &[],
4238            &[],
4239            "tok",
4240            "admin_csrf",
4241            "X-CSRF-Token",
4242            "/admin",
4243            "/actuator",
4244            false,
4245            false,
4246            false,
4247            None,
4248        )
4249        .into_string();
4250        // Form posts to the bulk-action endpoint with the CSRF token.
4251        assert!(
4252            html.contains(r#"action="/admin/widgets/actions""#),
4253            "list view must wrap table in a form posting to /actions: {html}"
4254        );
4255        assert!(
4256            html.contains(r#"name="admin_csrf" value="tok""#),
4257            "configured CSRF token field must be in the bulk-action form: {html}"
4258        );
4259        assert!(!html.contains(r#"name="_csrf" value="tok""#));
4260        // Both action options appear, with the dangerous one tagged for
4261        // client-side confirm.
4262        assert!(html.contains(r#"value="delete""#));
4263        assert!(html.contains(r#"value="archive""#));
4264        assert!(
4265            html.contains(r#"data-confirm="1""#),
4266            "destructive action should set data-confirm: {html}"
4267        );
4268        // Issue #1233: a shared confirm <dialog> is rendered because at
4269        // least one action (delete) requires confirmation. admin.js
4270        // intercepts the bulk submit and shows this dialog instead of
4271        // calling window.confirm().
4272        assert!(
4273            html.contains(r#"<dialog id="admin-bulk-confirm""#),
4274            "list view with a confirm-requiring action must render the bulk confirm dialog: {html}"
4275        );
4276        assert!(html.contains("data-bulk-confirm-detail"), "{html}");
4277        // Regression: "data-bulk-confirm" is a substring of
4278        // "data-bulk-confirm-detail", so a plain `.contains("data-bulk-confirm")`
4279        // would pass even if the Confirm button's own attribute were removed.
4280        // Require an occurrence NOT immediately followed by `-` (i.e. not part
4281        // of `-detail`) so this actually verifies the button's bare attribute.
4282        assert!(
4283            html.match_indices("data-bulk-confirm")
4284                .any(|(i, m)| html.as_bytes().get(i + m.len()) != Some(&b'-')),
4285            "confirm button must carry a standalone data-bulk-confirm attribute: {html}"
4286        );
4287        assert!(!html.contains("window.confirm"), "{html}");
4288    }
4289
4290    #[test]
4291    fn list_page_skips_bulk_confirm_dialog_when_no_action_requires_confirm() {
4292        use crate::traits::{ActionStyle, ListResult};
4293        let r = dummy_registry();
4294        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4295        let actions = vec![AdminAction {
4296            name: "archive",
4297            label: "Archive".to_owned(),
4298            style: ActionStyle::Default,
4299            confirm: false,
4300        }];
4301        let result = ListResult {
4302            records: vec![serde_json::json!({"id": 1, "name": "x"})],
4303            total: 1,
4304            page: 1,
4305            per_page: 25,
4306        };
4307        let html = model_list_page(
4308            &r,
4309            "widgets",
4310            "Widgets",
4311            &fields,
4312            &actions,
4313            &result,
4314            "",
4315            None,
4316            SortDirection::Asc,
4317            &[],
4318            &[],
4319            "tok",
4320            "admin_csrf",
4321            "X-CSRF-Token",
4322            "/admin",
4323            "/actuator",
4324            false,
4325            false,
4326            false,
4327            None,
4328        )
4329        .into_string();
4330        assert!(
4331            !html.contains("admin-bulk-confirm"),
4332            "no action requires confirmation, so no dialog should render: {html}"
4333        );
4334    }
4335
4336    #[test]
4337    fn list_page_skips_action_bar_when_no_actions_declared() {
4338        use crate::traits::ListResult;
4339        let r = dummy_registry();
4340        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4341        let result = ListResult {
4342            records: vec![],
4343            total: 0,
4344            page: 1,
4345            per_page: 25,
4346        };
4347        let html = model_list_page(
4348            &r,
4349            "widgets",
4350            "Widgets",
4351            &fields,
4352            &[], // no actions
4353            &result,
4354            "",
4355            None,
4356            SortDirection::Asc,
4357            &[],
4358            &[],
4359            "t",
4360            "_csrf",
4361            "X-CSRF-Token",
4362            "/admin",
4363            "/actuator",
4364            false,
4365            false,
4366            false,
4367            None,
4368        )
4369        .into_string();
4370        assert!(
4371            !html.contains("class=\"action-bar\""),
4372            "no action-bar should render when actions is empty: {html}"
4373        );
4374    }
4375
4376    #[test]
4377    fn list_page_omits_sort_link_for_unsortable_fields() {
4378        use crate::traits::ListResult;
4379        let r = dummy_registry();
4380        // One sortable, one non-sortable.
4381        let mut computed = AdminField::new("computed", AdminFieldKind::Text).label("Computed");
4382        computed.sortable = false;
4383        let fields = vec![AdminField::new("name", AdminFieldKind::Text), computed];
4384        let result = ListResult {
4385            records: vec![],
4386            total: 0,
4387            page: 1,
4388            per_page: 25,
4389        };
4390        let html = model_list_page(
4391            &r,
4392            "widgets",
4393            "Widgets",
4394            &fields,
4395            &[],
4396            &result,
4397            "",
4398            None,
4399            SortDirection::Asc,
4400            &[],
4401            &[],
4402            "tok",
4403            "_csrf",
4404            "X-CSRF-Token",
4405            "/admin",
4406            "/actuator",
4407            false,
4408            false,
4409            false,
4410            None,
4411        )
4412        .into_string();
4413        // Sortable field gets a sort link.
4414        assert!(
4415            html.contains(r#"href="/admin/widgets?sort=name"#),
4416            "sortable field should have a sort link: {html}"
4417        );
4418        // Non-sortable field must NOT get a sort link.
4419        assert!(
4420            !html.contains("sort=computed"),
4421            "non-sortable field must not emit a sort link: {html}"
4422        );
4423        // But its label is still rendered.
4424        assert!(
4425            html.contains("Computed"),
4426            "label should still render: {html}"
4427        );
4428    }
4429
4430    #[test]
4431    fn list_page_shows_csv_download_link_when_export_enabled() {
4432        use crate::traits::ListResult;
4433        let r = dummy_registry();
4434        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4435        let result = ListResult {
4436            records: vec![],
4437            total: 0,
4438            page: 1,
4439            per_page: 25,
4440        };
4441        // supports_csv_export = true, supports_csv_import = false
4442        let html = model_list_page(
4443            &r,
4444            "widgets",
4445            "Widgets",
4446            &fields,
4447            &[],
4448            &result,
4449            "",
4450            None,
4451            SortDirection::Asc,
4452            &[],
4453            &[],
4454            "t",
4455            "_csrf",
4456            "X-CSRF-Token",
4457            "/admin",
4458            "/actuator",
4459            false, // show_config
4460            true,  // supports_csv_export
4461            false, // supports_csv_import
4462            None,
4463        )
4464        .into_string();
4465        assert!(
4466            html.contains(r#"href="/admin/widgets/export.csv""#),
4467            "Download CSV link must appear when supports_csv_export=true: {html}"
4468        );
4469        assert!(
4470            !html.contains("/import"),
4471            "Import CSV link must not appear when supports_csv_import=false: {html}"
4472        );
4473    }
4474
4475    #[test]
4476    fn list_page_shows_import_link_when_import_enabled() {
4477        use crate::traits::ListResult;
4478        let r = dummy_registry();
4479        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4480        let result = ListResult {
4481            records: vec![],
4482            total: 0,
4483            page: 1,
4484            per_page: 25,
4485        };
4486        let html = model_list_page(
4487            &r,
4488            "widgets",
4489            "Widgets",
4490            &fields,
4491            &[],
4492            &result,
4493            "",
4494            None,
4495            SortDirection::Asc,
4496            &[],
4497            &[],
4498            "t",
4499            "_csrf",
4500            "X-CSRF-Token",
4501            "/admin",
4502            "/actuator",
4503            false, // show_config
4504            false, // supports_csv_export
4505            true,  // supports_csv_import
4506            None,
4507        )
4508        .into_string();
4509        assert!(
4510            html.contains(r#"href="/admin/widgets/import""#),
4511            "Import CSV link must appear when supports_csv_import=true: {html}"
4512        );
4513        assert!(
4514            !html.contains("export.csv"),
4515            "Download CSV link must not appear when supports_csv_export=false: {html}"
4516        );
4517    }
4518
4519    #[test]
4520    fn list_page_hides_csv_buttons_when_both_disabled() {
4521        use crate::traits::ListResult;
4522        let r = dummy_registry();
4523        let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4524        let result = ListResult {
4525            records: vec![],
4526            total: 0,
4527            page: 1,
4528            per_page: 25,
4529        };
4530        let html = model_list_page(
4531            &r,
4532            "widgets",
4533            "Widgets",
4534            &fields,
4535            &[],
4536            &result,
4537            "",
4538            None,
4539            SortDirection::Asc,
4540            &[],
4541            &[],
4542            "t",
4543            "_csrf",
4544            "X-CSRF-Token",
4545            "/admin",
4546            "/actuator",
4547            false,
4548            false,
4549            false,
4550            None,
4551        )
4552        .into_string();
4553        assert!(
4554            !html.contains("export.csv"),
4555            "no export link when disabled: {html}"
4556        );
4557        assert!(
4558            !html.contains("/import"),
4559            "no import link when disabled: {html}"
4560        );
4561    }
4562
4563    #[test]
4564    fn admin_js_does_not_contain_inline_event_handlers() {
4565        // Sanity-check the shipped JS: has the behaviours we expect.
4566        let js = include_str!("admin.js");
4567        assert!(
4568            js.contains("select-all"),
4569            "admin.js should wire the select-all checkbox"
4570        );
4571        assert!(
4572            js.contains("removeAttribute(\"name\")"),
4573            "admin.js should strip blank password input names"
4574        );
4575    }
4576
4577    #[test]
4578    fn admin_js_uses_confirm_dialog_as_primary_path() {
4579        // Issue #1233: the bulk-action confirm's primary path is the
4580        // server-rendered #admin-bulk-confirm <dialog>
4581        // (autumn_web::widgets::modal), not the native window.confirm().
4582        let js = include_str!("admin.js");
4583        assert!(js.contains("data-bulk-confirm"), "{js}");
4584        assert!(js.contains("showModal"), "{js}");
4585    }
4586
4587    #[test]
4588    fn admin_js_window_confirm_only_reached_as_showmodal_fallback() {
4589        // Code-review fix: window.confirm() must not be the primary confirm
4590        // mechanism, but it IS kept as a fallback for browsers without
4591        // <dialog>.showModal support — otherwise a destructive bulk action
4592        // would submit with zero confirmation on those browsers (fail-open).
4593        // Verify it's only reachable after the showModal support guard, not
4594        // called unconditionally earlier in the file.
4595        let js = include_str!("admin.js");
4596        let guard_idx = js
4597            .find("!dialog.showModal")
4598            .unwrap_or_else(|| panic!("must feature-detect <dialog>.showModal support: {js}"));
4599        // Search for the actual call site (not just the substring
4600        // "window.confirm", which also appears in an explanatory comment
4601        // earlier in the file).
4602        let confirm_idx = js
4603            .find("window.confirm(message)")
4604            .unwrap_or_else(|| panic!("must keep a window.confirm() fallback: {js}"));
4605        assert!(
4606            confirm_idx > guard_idx,
4607            "window.confirm() must only be reached after the showModal support guard: {js}"
4608        );
4609    }
4610
4611    #[test]
4612    fn admin_js_defers_showmodal_fallback_resubmit() {
4613        // Regression (caught by a real-browser Playwright check, not by any
4614        // string-matching test): calling form.requestSubmit() synchronously
4615        // from within that same form's still-dispatching `submit` event
4616        // handler is a no-op per the HTML spec's reentrancy guard ("if
4617        // form's firing submit event is true, then return"). The
4618        // window.confirm() fallback branch runs inside that handler, so its
4619        // resubmit must be deferred (e.g. via setTimeout) past the current
4620        // dispatch — otherwise the confirmed action silently never submits.
4621        let js = include_str!("admin.js");
4622        let confirm_idx = js
4623            .find("window.confirm(message)")
4624            .unwrap_or_else(|| panic!("must keep a window.confirm() fallback: {js}"));
4625        let defer_idx = js
4626            .find("setTimeout")
4627            .unwrap_or_else(|| panic!("fallback resubmit must be deferred: {js}"));
4628        assert!(
4629            defer_idx > confirm_idx,
4630            "the deferred resubmit must be inside the window.confirm() fallback branch: {js}"
4631        );
4632    }
4633
4634    #[test]
4635    fn admin_js_requestsubmit_has_form_submit_fallback() {
4636        // PR review (gemini-code-assist): form.requestSubmit() shipped later
4637        // than <dialog> support in some browsers (e.g. Safari 15.4-15.6 has
4638        // <dialog> but not requestSubmit, which arrived in Safari 16) and
4639        // may be absent in older headless test runners — calling it
4640        // unguarded throws a TypeError. Both call sites must go through a
4641        // feature-detected fallback to form.submit() instead of calling
4642        // requestSubmit directly.
4643        let js = include_str!("admin.js");
4644        assert!(
4645            js.contains(r#"typeof form.requestSubmit === "function""#),
4646            "must feature-detect requestSubmit before calling it: {js}"
4647        );
4648        assert!(
4649            js.contains("form.submit();"),
4650            "must fall back to form.submit() when requestSubmit is unavailable: {js}"
4651        );
4652        // The only *call statement* invoking requestSubmit() should be
4653        // inside the feature-detected submitForm() helper itself (matched
4654        // with a trailing `;` so an explanatory comment mentioning the same
4655        // method name doesn't also count) — every other resubmit call site
4656        // routes through submitForm(form) instead.
4657        assert_eq!(
4658            js.matches("form.requestSubmit();").count(),
4659            1,
4660            "form.requestSubmit() should only be called from inside the \
4661             feature-detected submitForm() helper, not unguarded elsewhere: {js}"
4662        );
4663        assert_eq!(
4664            js.matches("submitForm(form);").count(),
4665            2,
4666            "both bulk-action resubmit call sites must route through submitForm(): {js}"
4667        );
4668    }
4669
4670    #[test]
4671    fn admin_js_clears_bulk_confirmed_before_fallback_submit() {
4672        // PR review (chatgpt-codex-connector): form.submit() (the
4673        // requestSubmit-unavailable fallback) bypasses the 'submit' event
4674        // entirely, so the top-level submit handler's `bulkConfirmed`
4675        // cleanup never runs for that path. Without an explicit clear here,
4676        // a bfcache-restored page after that fallback submit would carry a
4677        // stale "confirmed" flag into the next, unrelated bulk-action
4678        // attempt and skip its confirmation dialog. The clear must happen
4679        // in the same branch as (and before) the form.submit() fallback
4680        // call, not just "somewhere in the file".
4681        let js = include_str!("admin.js");
4682        let fallback_idx = js
4683            .find("form.submit();")
4684            .unwrap_or_else(|| panic!("must keep a form.submit() fallback: {js}"));
4685        // `find` (first occurrence), not `rfind`: the top-level submit-event
4686        // handler has its own, unrelated "delete ...;" later in the file
4687        // (its normal one-shot-flag consumption on a genuine event) — this
4688        // must find the one inside submitForm() itself, which comes first.
4689        let clear_idx = js
4690            .find("delete form.dataset.bulkConfirmed;")
4691            .unwrap_or_else(|| {
4692                panic!("must clear bulkConfirmed before the form.submit() fallback: {js}")
4693            });
4694        assert!(
4695            clear_idx < fallback_idx,
4696            "bulkConfirmed must be cleared BEFORE the form.submit() fallback call \
4697             (form.submit() bypasses the submit event, so clearing it after — or \
4698             relying on the submit-event listener to clear it — never happens): {js}"
4699        );
4700        // Clearing must live inside submitForm() itself, not just at one of
4701        // its call sites, so it applies regardless of which call site
4702        // triggers the fallback path.
4703        let submit_form_idx = js
4704            .find("function submitForm(form)")
4705            .unwrap_or_else(|| panic!("submitForm() helper must exist: {js}"));
4706        assert!(
4707            submit_form_idx < clear_idx && clear_idx < fallback_idx,
4708            "the bulkConfirmed clear must be inside submitForm(), immediately \
4709             guarding its form.submit() fallback: {js}"
4710        );
4711    }
4712
4713    #[test]
4714    fn pagination_range_start_underflow_protection() {
4715        // The start calculation could previously panic in debug mode if current was 0.
4716        let result = crate::traits::ListResult {
4717            total: 10,
4718            per_page: 5,
4719            page: 0,
4720            records: vec![],
4721        };
4722        // render_pagination itself expects the request page, which is usually clamped to >=1,
4723        // but just to verify it won't panic if it somehow gets 0:
4724        let _ = render_pagination(
4725            &result,
4726            "y",
4727            "x",
4728            None,
4729            crate::traits::SortDirection::Asc,
4730            "",
4731            "",
4732        );
4733    }
4734
4735    // ── Runtime config page tests ────────────────────────────────────────────
4736
4737    #[test]
4738    fn config_page_empty_shows_no_keys_registered_message() {
4739        let r = dummy_registry();
4740        let html = config_page(
4741            &r,
4742            &[],
4743            &[],
4744            "tok",
4745            "_csrf",
4746            "X-CSRF-Token",
4747            "/admin",
4748            "/actuator",
4749            None,
4750        )
4751        .into_string();
4752        assert!(
4753            html.contains("No config keys have been registered"),
4754            "empty state message missing: {html}"
4755        );
4756        assert!(
4757            html.contains("Runtime Config"),
4758            "page title missing: {html}"
4759        );
4760    }
4761
4762    #[test]
4763    fn config_page_renders_key_name_type_and_value() {
4764        use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
4765
4766        let r = dummy_registry();
4767        let entries = vec![ConfigEntry {
4768            name: "max_upload_mb".to_owned(),
4769            value_type: ConfigValueType::Int,
4770            current: ConfigValue::Int(50),
4771            default: ConfigValue::Int(50),
4772            is_overridden: false,
4773            description: Some("Max upload in MB".to_owned()),
4774        }];
4775        let html = config_page(
4776            &r,
4777            &entries,
4778            &[],
4779            "tok",
4780            "_csrf",
4781            "X-CSRF-Token",
4782            "/admin",
4783            "/actuator",
4784            None,
4785        )
4786        .into_string();
4787        assert!(html.contains("max_upload_mb"), "key name missing: {html}");
4788        assert!(
4789            html.contains("Max upload in MB"),
4790            "description missing: {html}"
4791        );
4792        assert!(
4793            html.contains(r#"action="/admin/config/max_upload_mb/set""#),
4794            "set form action missing: {html}"
4795        );
4796        assert!(
4797            html.contains(r#"href="/admin/config/max_upload_mb/history""#),
4798            "history link missing: {html}"
4799        );
4800    }
4801
4802    #[test]
4803    fn config_page_overridden_key_shows_unset_form() {
4804        use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
4805
4806        let r = dummy_registry();
4807        let entries = vec![ConfigEntry {
4808            name: "rate_limit".to_owned(),
4809            value_type: ConfigValueType::Int,
4810            current: ConfigValue::Int(200),
4811            default: ConfigValue::Int(100),
4812            is_overridden: true,
4813            description: None,
4814        }];
4815        let html = config_page(
4816            &r,
4817            &entries,
4818            &[],
4819            "tok",
4820            "_csrf",
4821            "X-CSRF-Token",
4822            "/admin",
4823            "/actuator",
4824            None,
4825        )
4826        .into_string();
4827        assert!(
4828            html.contains(r#"action="/admin/config/rate_limit/unset""#),
4829            "unset form should appear for overridden key: {html}"
4830        );
4831    }
4832
4833    #[test]
4834    fn config_page_shows_overridden_status() {
4835        use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
4836
4837        let r = dummy_registry();
4838        let entries = vec![ConfigEntry {
4839            name: "rate_limit".to_owned(),
4840            value_type: ConfigValueType::Int,
4841            current: ConfigValue::Int(200),
4842            default: ConfigValue::Int(100),
4843            is_overridden: true,
4844            description: None,
4845        }];
4846        let html = config_page(
4847            &r,
4848            &entries,
4849            &[],
4850            "tok",
4851            "_csrf",
4852            "X-CSRF-Token",
4853            "/admin",
4854            "/actuator",
4855            None,
4856        )
4857        .into_string();
4858        assert!(
4859            html.to_lowercase().contains("overridden"),
4860            "overridden status missing: {html}"
4861        );
4862    }
4863
4864    #[test]
4865    fn config_page_shows_default_status_for_unoverridden_key() {
4866        use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
4867
4868        let r = dummy_registry();
4869        let entries = vec![ConfigEntry {
4870            name: "feature_flag".to_owned(),
4871            value_type: ConfigValueType::Bool,
4872            current: ConfigValue::Bool(false),
4873            default: ConfigValue::Bool(false),
4874            is_overridden: false,
4875            description: None,
4876        }];
4877        let html = config_page(
4878            &r,
4879            &entries,
4880            &[],
4881            "tok",
4882            "_csrf",
4883            "X-CSRF-Token",
4884            "/admin",
4885            "/actuator",
4886            None,
4887        )
4888        .into_string();
4889        assert!(
4890            html.to_lowercase().contains("default"),
4891            "default status missing: {html}"
4892        );
4893    }
4894
4895    #[test]
4896    fn config_page_embeds_csrf_token_in_forms() {
4897        use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
4898
4899        let r = dummy_registry();
4900        let entries = vec![ConfigEntry {
4901            name: "timeout_secs".to_owned(),
4902            value_type: ConfigValueType::Int,
4903            current: ConfigValue::Int(30),
4904            default: ConfigValue::Int(30),
4905            is_overridden: false,
4906            description: None,
4907        }];
4908        let html = config_page(
4909            &r,
4910            &entries,
4911            &[],
4912            "csrf-tok-789",
4913            "authenticity_token",
4914            "X-CSRF-Token",
4915            "/admin",
4916            "/actuator",
4917            None,
4918        )
4919        .into_string();
4920        assert!(
4921            html.contains(r#"name="authenticity_token" value="csrf-tok-789""#),
4922            "CSRF token not embedded in config forms: {html}"
4923        );
4924    }
4925
4926    #[test]
4927    fn config_history_page_shows_empty_state() {
4928        let r = dummy_registry();
4929        let html = config_history_page(
4930            &r,
4931            "rate_limit",
4932            &[],
4933            &[],
4934            "tok",
4935            "X-CSRF-Token",
4936            "/admin",
4937            "/actuator",
4938            None,
4939        )
4940        .into_string();
4941        assert!(
4942            html.contains("No changes recorded"),
4943            "empty history message missing: {html}"
4944        );
4945        assert!(html.contains("rate_limit"), "key name missing: {html}");
4946    }
4947
4948    #[test]
4949    fn config_history_page_renders_change_records() {
4950        use autumn_web::runtime_config::{ConfigChangeRecord, ConfigValue};
4951
4952        let r = dummy_registry();
4953        let history = vec![ConfigChangeRecord {
4954            key: "rate_limit".to_owned(),
4955            old_value: Some(ConfigValue::Int(100)),
4956            new_value: Some(ConfigValue::Int(200)),
4957            actor: Some("ops@example.com".to_owned()),
4958            timestamp_secs: 1_700_000_000,
4959        }];
4960        let html = config_history_page(
4961            &r,
4962            "rate_limit",
4963            &history,
4964            &[],
4965            "tok",
4966            "X-CSRF-Token",
4967            "/admin",
4968            "/actuator",
4969            None,
4970        )
4971        .into_string();
4972        assert!(html.contains("rate_limit"), "key name missing: {html}");
4973        assert!(html.contains("ops@example.com"), "actor missing: {html}");
4974        assert!(html.contains("100"), "old value missing: {html}");
4975        assert!(html.contains("200"), "new value missing: {html}");
4976    }
4977
4978    #[test]
4979    fn config_history_page_handles_unset_record() {
4980        use autumn_web::runtime_config::{ConfigChangeRecord, ConfigValue};
4981
4982        let r = dummy_registry();
4983        let history = vec![ConfigChangeRecord {
4984            key: "flag".to_owned(),
4985            old_value: Some(ConfigValue::Bool(true)),
4986            new_value: None,
4987            actor: None,
4988            timestamp_secs: 0,
4989        }];
4990        let html = config_history_page(
4991            &r,
4992            "flag",
4993            &history,
4994            &[],
4995            "tok",
4996            "X-CSRF-Token",
4997            "/admin",
4998            "/actuator",
4999            None,
5000        )
5001        .into_string();
5002        assert!(html.contains("flag"), "key name missing: {html}");
5003        // The null actor should render as a dash placeholder.
5004        assert!(html.contains("—"), "null actor placeholder missing: {html}");
5005    }
5006
5007    #[test]
5008    fn format_timestamp_formats_unix_epoch() {
5009        let s = format_timestamp(0);
5010        assert!(s.contains("1970"), "epoch should format as 1970: {s}");
5011    }
5012
5013    #[test]
5014    fn format_timestamp_formats_known_instant() {
5015        // 2023-11-14 22:13:20 UTC
5016        let s = format_timestamp(1_700_000_000);
5017        assert!(s.contains("2023"), "expected 2023 in formatted output: {s}");
5018    }
5019
5020    // ── Impersonation banner (#1394) ─────────────────────────────────────
5021
5022    fn banner_state(target: &str, operator: &str) -> ImpersonationBanner {
5023        ImpersonationBanner {
5024            effective_user_id: target.to_owned(),
5025            impersonator_id: operator.to_owned(),
5026            admin_prefix: "/admin".to_owned(),
5027            csrf_token: String::new(),
5028            csrf_form_field: String::new(),
5029            return_to: String::new(),
5030        }
5031    }
5032
5033    #[test]
5034    fn banner_names_both_parties_and_offers_a_revert() {
5035        let html = impersonation_banner(&banner_state("user-9", "admin-1")).into_string();
5036        assert!(html.contains("Viewing as"), "{html}");
5037        assert!(html.contains("user-9"), "{html}");
5038        assert!(html.contains("admin-1"), "{html}");
5039        assert!(html.contains("Stop impersonating"), "{html}");
5040        assert!(
5041            html.contains(r#"action="/admin/impersonate/stop""#),
5042            "{html}"
5043        );
5044        assert!(html.contains(r#"method="post""#), "{html}");
5045    }
5046
5047    #[test]
5048    fn banner_omits_the_csrf_field_when_no_token_is_available() {
5049        // `CsrfLayer` is off outside the prod profile; rendering `name="" value=""`
5050        // would be a broken field rather than an absent one.
5051        let html = impersonation_banner(&banner_state("user-9", "admin-1")).into_string();
5052        assert!(!html.contains(r#"type="hidden""#), "{html}");
5053    }
5054
5055    #[test]
5056    fn banner_renders_the_csrf_field_with_the_configured_name() {
5057        let mut banner = banner_state("user-9", "admin-1");
5058        banner.csrf_token = "tok-123".to_owned();
5059        banner.csrf_form_field = "authenticity_token".to_owned();
5060        let html = impersonation_banner(&banner).into_string();
5061        assert!(
5062            html.contains(r#"name="authenticity_token" value="tok-123""#),
5063            "{html}"
5064        );
5065    }
5066
5067    #[test]
5068    fn banner_falls_back_to_the_default_csrf_field_name() {
5069        let mut banner = banner_state("user-9", "admin-1");
5070        banner.csrf_token = "tok-123".to_owned();
5071        let html = impersonation_banner(&banner).into_string();
5072        assert!(html.contains(r#"name="_csrf" value="tok-123""#), "{html}");
5073    }
5074
5075    #[test]
5076    fn banner_action_survives_a_trailing_slash_on_the_prefix() {
5077        let mut banner = banner_state("user-9", "admin-1");
5078        banner.admin_prefix = "/back-office/".to_owned();
5079        let html = impersonation_banner(&banner).into_string();
5080        assert!(
5081            html.contains(r#"action="/back-office/impersonate/stop""#),
5082            "{html}"
5083        );
5084    }
5085
5086    #[test]
5087    fn banner_carries_return_to_only_when_set() {
5088        let plain = impersonation_banner(&banner_state("user-9", "admin-1")).into_string();
5089        assert!(!plain.contains("return_to"), "{plain}");
5090
5091        let with_return =
5092            impersonation_banner(&banner_state("user-9", "admin-1").returning_to("/dashboard"))
5093                .into_string();
5094        assert!(
5095            with_return.contains(r#"name="return_to" value="/dashboard""#),
5096            "{with_return}"
5097        );
5098    }
5099
5100    #[test]
5101    fn banner_escapes_the_user_ids_it_renders() {
5102        let html = impersonation_banner(&banner_state("<script>alert(1)</script>", "admin-1"))
5103            .into_string();
5104        assert!(!html.contains("<script>"), "{html}");
5105        assert!(html.contains("&lt;script&gt;"), "{html}");
5106    }
5107
5108    #[test]
5109    fn the_admin_layout_ships_the_banner_styles() {
5110        assert!(
5111            IMPERSONATION_BANNER_CSS.contains(".autumn-impersonation-banner"),
5112            "{IMPERSONATION_BANNER_CSS}"
5113        );
5114    }
5115
5116    // ── admin_layout nav (#1134) ─────────────────────────────────────────
5117
5118    fn render_layout(active_slug: Option<&str>) -> String {
5119        let registry = AdminRegistry::new();
5120        admin_layout(
5121            &registry,
5122            active_slug,
5123            "Title",
5124            "/admin",
5125            "/actuator",
5126            "tok",
5127            "X-CSRF-Token",
5128            &[],
5129            true,
5130            None,
5131            &html! {},
5132        )
5133        .into_string()
5134    }
5135
5136    #[test]
5137    fn admin_layout_dashboard_active_has_aria_current() {
5138        let html = render_layout(None);
5139        assert_eq!(html.matches(r#"aria-current="page""#).count(), 1, "{html}");
5140        assert!(
5141            html.contains(r#"href="/admin" class="autumn-active" aria-current="page""#),
5142            "{html}"
5143        );
5144    }
5145
5146    #[test]
5147    fn admin_layout_jobs_active_has_aria_current() {
5148        let html = render_layout(Some(JOBS_NAV_SLUG));
5149        assert_eq!(html.matches(r#"aria-current="page""#).count(), 1, "{html}");
5150        assert!(
5151            html.contains(r#"href="/admin/jobs" class="autumn-active" aria-current="page""#),
5152            "{html}"
5153        );
5154    }
5155
5156    #[test]
5157    fn admin_layout_runtime_config_active_has_aria_current() {
5158        let html = render_layout(Some(RUNTIME_CONFIG_NAV_SLUG));
5159        assert_eq!(html.matches(r#"aria-current="page""#).count(), 1, "{html}");
5160        assert!(
5161            html.contains(r#"href="/admin/config" class="autumn-active" aria-current="page""#),
5162            "{html}"
5163        );
5164    }
5165
5166    #[test]
5167    fn admin_layout_no_active_slug_has_no_dashboard_aria_current() {
5168        // active_slug = Some(...) for an unrelated slug: Dashboard must not
5169        // claim the active state, and nothing else should either since the
5170        // registry is empty (no model nav items).
5171        let html = render_layout(Some(JOBS_NAV_SLUG));
5172        assert!(
5173            !html.contains(r#"href="/admin" class="autumn-active""#),
5174            "dashboard must not be active: {html}"
5175        );
5176    }
5177
5178    // ── admin_layout nav_bar (#1137) ──────────────────────────────────────
5179
5180    use crate::registry::tests::DummyModel;
5181
5182    fn render_layout_with_registry(registry: &AdminRegistry, active_slug: Option<&str>) -> String {
5183        admin_layout(
5184            registry,
5185            active_slug,
5186            "Title",
5187            "/admin",
5188            "/actuator",
5189            "tok",
5190            "X-CSRF-Token",
5191            &[],
5192            true,
5193            None,
5194            &html! {},
5195        )
5196        .into_string()
5197    }
5198
5199    #[test]
5200    fn admin_layout_renders_nav_bar_sidebar() {
5201        let html = render_layout(None);
5202        assert!(html.contains("autumn-nav--sidebar"), "{html}");
5203        assert!(
5204            html.contains(r#"class="autumn-nav autumn-nav--sidebar admin-sidebar""#),
5205            "{html}"
5206        );
5207        assert!(html.contains(r#"aria-label="Admin navigation""#), "{html}");
5208    }
5209
5210    #[test]
5211    fn admin_layout_brand_uses_nav_brand_class() {
5212        let html = render_layout(None);
5213        assert!(html.contains("autumn-nav__brand"), "{html}");
5214        assert!(html.contains("🍂 Autumn Admin"), "{html}");
5215        assert!(!html.contains(r#"class="admin-logo""#), "{html}");
5216    }
5217
5218    #[test]
5219    fn admin_layout_sections_render_as_nav_sections_without_models() {
5220        let html = render_layout(None);
5221        assert!(
5222            html.contains(r#"<li class="autumn-nav__section">System</li>"#),
5223            "{html}"
5224        );
5225        assert!(!html.contains("Models"), "{html}");
5226    }
5227
5228    #[test]
5229    fn admin_layout_sections_render_as_nav_sections_with_models() {
5230        let mut registry = AdminRegistry::new();
5231        registry.register(DummyModel {
5232            slug: "projects",
5233            name: "Projects",
5234        });
5235        let html = render_layout_with_registry(&registry, None);
5236        assert!(
5237            html.contains(r#"<li class="autumn-nav__section">Models</li>"#),
5238            "{html}"
5239        );
5240        assert!(
5241            html.contains(r#"<li class="autumn-nav__section">System</li>"#),
5242            "{html}"
5243        );
5244        assert!(html.contains(r#"href="/admin/projects""#), "{html}");
5245    }
5246
5247    #[test]
5248    fn admin_layout_actuator_link_is_plain() {
5249        let html = render_layout(None);
5250        assert!(html.contains(r#"href="/actuator/ui""#), "{html}");
5251        // Only one aria-current in the whole page: Dashboard's. The Actuator
5252        // link must never claim active state even though it's the last item.
5253        assert_eq!(html.matches(r#"aria-current="page""#).count(), 1, "{html}");
5254    }
5255
5256    #[test]
5257    fn admin_layout_has_no_hand_rolled_nav_markup() {
5258        let html = render_layout(None);
5259        assert!(!html.contains(r#"class="admin-nav""#), "{html}");
5260        assert!(!html.contains("admin-logo"), "{html}");
5261    }
5262
5263    #[test]
5264    fn admin_layout_loads_nav_bar_widget_runtime() {
5265        // nav_bar's hamburger toggle and any future dropdown depend on
5266        // autumn-widgets.js to reveal/wire them up; without it the toggle
5267        // stays permanently hidden and dead.
5268        let html = render_layout(None);
5269        let widgets_src = format!(
5270            r#"src="{}""#,
5271            autumn_web::assets::asset_url("js/autumn-widgets.js")
5272        );
5273        assert!(html.contains(&widgets_src), "{html}");
5274    }
5275
5276    #[test]
5277    fn admin_sidebar_toggle_stays_hidden() {
5278        // The admin sidebar hides itself entirely below 768px (see the
5279        // .admin-sidebar { display: none } media rule) rather than using
5280        // nav_bar's own collapse-behind-a-hamburger UX, so the toggle must
5281        // never become visible even once autumn-widgets.js unhides it.
5282        assert!(ADMIN_CSS.contains(".autumn-nav__toggle"), "{ADMIN_CSS}");
5283    }
5284
5285    // ── Flash rendering via shared helper (#1240) ─────────────────────────
5286
5287    #[test]
5288    fn admin_layout_renders_flash_via_shared_helper() {
5289        use autumn_web::flash::FlashLevel;
5290
5291        let registry = AdminRegistry::new();
5292        let messages = [
5293            FlashMessage {
5294                level: FlashLevel::Success,
5295                message: "Saved!".into(),
5296            },
5297            FlashMessage {
5298                level: FlashLevel::Error,
5299                message: "Boom".into(),
5300            },
5301        ];
5302        let html = admin_layout(
5303            &registry,
5304            None,
5305            "Title",
5306            "/admin",
5307            "/actuator",
5308            "tok",
5309            "X-CSRF-Token",
5310            &messages,
5311            true,
5312            None,
5313            &html! {},
5314        )
5315        .into_string();
5316
5317        // Shared flash_messages() markup: grouped container + semantic
5318        // per-level classes.
5319        assert!(html.contains("autumn-flash-group"), "{html}");
5320        assert!(html.contains("autumn-flash--success"), "{html}");
5321        assert!(html.contains("autumn-flash--error"), "{html}");
5322        assert!(html.contains("Saved!") && html.contains("Boom"), "{html}");
5323
5324        // Severity-driven live-region semantics come from the shared helper:
5325        // Success → polite status, Error → assertive alert.
5326        assert!(html.contains(r#"role="status""#), "{html}");
5327        assert!(html.contains(r#"aria-live="polite""#), "{html}");
5328        assert!(html.contains(r#"role="alert""#), "{html}");
5329        assert!(html.contains(r#"aria-live="assertive""#), "{html}");
5330
5331        // The old hand-rolled `flash flash-<level>` banner markup is gone.
5332        assert!(!html.contains("flash flash-"), "{html}");
5333
5334        // The shared flash stylesheet is inlined so the migrated banners are
5335        // actually styled (this selector only exists in the shared FLASH_CSS).
5336        assert!(html.contains(".autumn-flash-group{"), "{html}");
5337    }
5338}