1use 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
30const 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
403pub 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#[derive(Debug, Clone)]
454#[non_exhaustive]
455pub struct ImpersonationBanner {
456 pub effective_user_id: String,
458 pub impersonator_id: String,
460 pub admin_prefix: String,
463 pub csrf_token: String,
466 pub csrf_form_field: String,
468 pub return_to: String,
472}
473
474impl ImpersonationBanner {
475 #[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 #[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 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#[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#[derive(Clone, Copy)]
567pub struct AdminChrome<'a> {
568 pub registry: &'a AdminRegistry,
570 pub prefix: &'a str,
572 pub actuator_prefix: &'a str,
574 pub csrf_token: &'a str,
576 pub csrf_form_field: &'a str,
578 pub csrf_token_header: &'a str,
580 pub messages: &'a [FlashMessage],
582 pub show_config: bool,
584 pub impersonation: Option<&'a ImpersonationBanner>,
586}
587
588#[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 pub filters: &'a [(String, String)],
602 pub supports_csv_export: bool,
603 pub supports_csv_import: bool,
604}
605
606#[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 pub id: i64,
619 pub has_history: bool,
620}
621
622#[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 pub id: Option<i64>,
633 pub raw_fields: &'a [&'a str],
637}
638
639pub 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 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 meta name="csrf-token" content=(csrf_token) data-header=(csrf_token_header);
706 title { (title) " — Autumn Admin" }
707 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 script src=(autumn_web::assets::asset_url("js/autumn-widgets.js")) defer {}
716 (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 a href="#admin-main" class="admin-skip-link" { "Skip to main content" }
728 @if let Some(banner) = impersonation {
731 (impersonation_banner(banner))
732 }
733 div class="admin-layout" {
734 header role="banner" {
736 (nav_bar(¤t_path, &sidebar_nav))
737 }
738 main id="admin-main" class="admin-main" {
740 (flash_messages(messages))
741 (content)
742 }
743 }
744 }
745 }
746 }
747}
748
749fn 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
757pub 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
834pub 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
1067pub fn dashboard_page(
1071 chrome: &AdminChrome<'_>,
1072 model_counts: &[(&str, &str, u64)], ) -> 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 ({
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#[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 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 let filters_enc = encode_filter_suffix(filters);
1145 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 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 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 form method="post" action={ (prefix) "/" (model_slug) "/actions" } {
1224 (csrf_hidden_input(csrf_token, csrf_form_field))
1225
1226 div class="table-wrap" {
1228 table {
1229 thead {
1230 tr {
1231 th class="checkbox-cell" {
1232 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 @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 span style="color: var(--text-muted); font-size: 0.75rem;" {
1295 "no id"
1296 }
1297 }
1298 }
1299 }
1300 }
1301 }
1302 }
1303 }
1304
1305 @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 } @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 @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
1359pub 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
1445pub 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
1542pub 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
1617pub 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 (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#[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 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
1822pub 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 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
1917fn render_cell_value(record: &Value, field: &AdminField) -> Markup {
1921 if matches!(field.kind, AdminFieldKind::Password) {
1925 return html! { "••••••••" };
1926 }
1927 if field.encrypted && !field.encrypted_visible {
1933 return html! { span title="encrypted at rest" { "••••••••" } };
1934 }
1935 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
1959fn 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
1980fn 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
1995fn 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
2006fn normalize_date_input(s: &str) -> String {
2011 if s.is_empty() {
2012 return String::new();
2013 }
2014 if chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
2016 return s.to_owned();
2017 }
2018 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
2020 return dt.format("%Y-%m-%d").to_string();
2021 }
2022 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
2029fn normalize_datetime_local_input(s: &str) -> String {
2044 if s.is_empty() {
2045 return String::new();
2046 }
2047 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 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
2063fn render_detail_value(record: &Value, field: &AdminField) -> Markup {
2065 if field.encrypted && !field.encrypted_visible {
2068 return html! { span title="encrypted at rest" { "••••••••" } };
2069 }
2070 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 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
2109fn render_readonly_display(field: &AdminField, record: Option<&Value>) -> Markup {
2114 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#[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 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 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 ¤t_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 let json_val = if raw_fields.contains(&field.name) || field.encrypted {
2306 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
2333fn 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 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 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#[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 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 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 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 #[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 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 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 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 #[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 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 #[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 #[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 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 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 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 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 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 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(""hello"") || form.contains("\"hello\""),
2921 "stored JSON string must render with its quotes intact: {form}"
2922 );
2923
2924 let record = serde_json::json!({ "config": "true" });
2927 let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
2928 assert!(
2929 form.contains(""true"") || form.contains("\"true\""),
2930 "a stored JSON string \"true\" must not render as the bare word true: {form}"
2931 );
2932
2933 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("{"a":1}") || form.contains(r#"{"a":1}"#),
2938 "object values still render as JSON: {form}"
2939 );
2940
2941 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 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 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 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); 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 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 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 let s = "日本語日";
3063 assert_eq!(truncate_display(s, 3), "日本…");
3064 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 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 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 assert_eq!(
3114 normalize_datetime_local_input("2026-04-24T12:34:56+05:30"),
3115 "2026-04-24T12:34"
3116 );
3117 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 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 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&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&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 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 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 #[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 #[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 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 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 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 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 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 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 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 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("<script>alert(1)</script>"),
3707 "escaped form expected: {html}"
3708 );
3709 }
3710
3711 #[test]
3712 fn layout_loads_external_admin_js_not_inline() {
3713 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 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 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 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 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 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 assert!(
3884 html.contains(r#"href="/admin/widgets/7""#),
3885 "row with id should have working View link: {html}"
3886 );
3887 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 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 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 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 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 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 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 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 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 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 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 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: &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 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 assert!(
4248 html.contains(r#"href="/admin/widgets?sort=name"#),
4249 "sortable field should have a sort link: {html}"
4250 );
4251 assert!(
4253 !html.contains("sort=computed"),
4254 "non-sortable field must not emit a sort link: {html}"
4255 );
4256 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 let html = model_list_page(
4276 &AdminChrome {
4277impersonation: 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: true,
4293supports_csv_import: 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: 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: false,
4337supports_csv_import: 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 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 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 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 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 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 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 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 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 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 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 let result = crate::traits::ListResult {
4544 total: 10,
4545 per_page: 5,
4546 page: 0,
4547 records: vec![],
4548 };
4549 let _ = render_pagination(
4552 &result,
4553 "y",
4554 "x",
4555 None,
4556 crate::traits::SortDirection::Asc,
4557 "",
4558 "",
4559 );
4560 }
4561
4562 #[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 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 let s = format_timestamp(1_700_000_000);
4812 assert!(s.contains("2023"), "expected 2023 in formatted output: {s}");
4813 }
4814
4815 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 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("<script>"), "{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 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(®istry)
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 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 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(®istry, 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 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 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 assert!(ADMIN_CSS.contains(".autumn-nav__toggle"), "{ADMIN_CSS}");
5072 }
5073
5074 #[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(®istry)
5097 },
5098 None,
5099 "Title",
5100 &html! {},
5101 )
5102 .into_string();
5103
5104 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 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 assert!(!html.contains("flash flash-"), "{html}");
5120
5121 assert!(html.contains(".autumn-flash-group{"), "{html}");
5124 }
5125}