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