Skip to main content

autumn_admin_plugin/
templates.rs

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