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
28use autumn_web::ui::tokens::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: var(--on-primary);
45 border-radius: 0 0 0.25rem 0.25rem;
46 font-size: 0.875rem;
47 text-decoration: none;
48 }
49 .admin-skip-link:focus {
50 top: 0;
51 outline: 3px solid var(--primary);
52 outline-offset: 2px;
53 }
54 * { box-sizing: border-box; margin: 0; padding: 0; }
55 body {
56 font-family: var(--font-family);
57 background: var(--bg);
58 color: var(--text);
59 line-height: 1.5;
60 }
61 a { color: var(--primary); text-decoration: none; }
62 a:hover { text-decoration: underline; }
63
64 /* Layout */
65 .admin-layout { display: flex; min-height: 100vh; }
66 .admin-sidebar {
67 width: 240px;
68 background: var(--surface);
69 border-right: 1px solid var(--border);
70 padding: 1.5rem 0;
71 position: fixed;
72 top: 0;
73 left: 0;
74 bottom: 0;
75 overflow-y: auto;
76 }
77 .admin-main {
78 margin-left: 240px;
79 flex: 1;
80 padding: 2rem;
81 min-width: 0;
82 }
83 .admin-sidebar .autumn-nav__brand {
84 display: block;
85 font-size: 1.125rem;
86 font-weight: 700;
87 padding: 0 1.5rem 1rem;
88 border-bottom: 1px solid var(--border);
89 margin-bottom: 1rem;
90 color: var(--text);
91 }
92 .admin-sidebar .autumn-nav__items { list-style: none; }
93 .admin-sidebar .autumn-nav__item a {
94 display: block;
95 padding: 0.5rem 1.5rem;
96 color: var(--text-muted);
97 font-size: 0.875rem;
98 font-weight: 500;
99 border-left: 3px solid transparent;
100 transition: all 0.15s;
101 }
102 .admin-sidebar .autumn-nav__item a:hover {
103 background: var(--bg);
104 color: var(--text);
105 text-decoration: none;
106 }
107 .admin-sidebar .autumn-nav__item a.active {
108 background: var(--primary-light);
109 color: var(--primary);
110 border-left-color: var(--primary);
111 }
112 .admin-sidebar .autumn-nav__section {
113 font-size: 0.7rem;
114 text-transform: uppercase;
115 letter-spacing: 0.05em;
116 color: var(--text-muted);
117 padding: 1rem 1.5rem 0.375rem;
118 font-weight: 600;
119 }
120 /* The sidebar hides itself entirely below 768px (see the .admin-sidebar
121 rule in the Responsive section) instead of collapsing behind nav_bar's
122 own hamburger toggle, so the toggle stays hidden at every width. */
123 .admin-sidebar .autumn-nav__toggle {
124 display: none;
125 }
126
127 /* Cards */
128 .autumn-card {
129 background: var(--surface);
130 border-radius: var(--radius);
131 box-shadow: var(--shadow);
132 margin-bottom: 1.5rem;
133 }
134 .autumn-card__header {
135 display: flex;
136 justify-content: space-between;
137 align-items: center;
138 padding: 1rem 1.5rem 0.75rem;
139 border-bottom: 1px solid var(--border);
140 }
141 .header-actions form { display: inline; }
142 .autumn-card__title {
143 font-size: 1.125rem;
144 font-weight: 600;
145 margin: 0;
146 }
147 .autumn-card__body {
148 padding: 1.5rem;
149 }
150
151 /* Buttons */
152 .btn {
153 display: inline-flex;
154 align-items: center;
155 gap: 0.375rem;
156 padding: 0.5rem 1rem;
157 border-radius: 0.375rem;
158 font-size: 0.875rem;
159 font-weight: 500;
160 border: 1px solid var(--border);
161 background: var(--surface);
162 color: var(--text);
163 cursor: pointer;
164 transition: all 0.15s;
165 }
166 .btn:hover { background: var(--bg); text-decoration: none; }
167 .btn-primary {
168 background: var(--primary);
169 color: var(--on-primary);
170 border-color: var(--primary);
171 }
172 .btn-primary:hover { background: var(--primary-hover); }
173 .btn-danger {
174 background: var(--danger);
175 color: var(--on-danger);
176 border-color: var(--danger);
177 }
178 .btn-danger:hover { background: var(--danger-hover); }
179 .btn-sm { padding: 0.25rem 0.625rem; font-size: 0.8125rem; }
180
181 /* Tables */
182 .table-wrap { overflow-x: auto; }
183 table {
184 width: 100%;
185 border-collapse: collapse;
186 font-size: 0.875rem;
187 }
188 th {
189 text-align: left;
190 padding: 0.75rem;
191 font-weight: 600;
192 color: var(--text-muted);
193 font-size: 0.75rem;
194 text-transform: uppercase;
195 letter-spacing: 0.05em;
196 border-bottom: 2px solid var(--border);
197 white-space: nowrap;
198 user-select: none;
199 }
200 th a { cursor: pointer; }
201 th a:hover { color: var(--text); }
202 th .sort-icon { font-size: 0.625rem; margin-left: 0.25rem; }
203 td {
204 padding: 0.75rem;
205 border-bottom: 1px solid var(--border);
206 max-width: 300px;
207 overflow: hidden;
208 text-overflow: ellipsis;
209 white-space: nowrap;
210 }
211 tr:hover td { background: var(--bg); }
212 .checkbox-cell { width: 40px; text-align: center; }
213
214 /* Forms */
215 .form-group { margin-bottom: 1rem; }
216 .form-label {
217 display: block;
218 font-size: 0.875rem;
219 font-weight: 500;
220 margin-bottom: 0.375rem;
221 color: var(--text);
222 }
223 .form-label .required { color: var(--danger); margin-left: 0.125rem; }
224 .form-input {
225 width: 100%;
226 padding: 0.5rem 0.75rem;
227 border: 1px solid var(--control-border);
228 border-radius: 0.375rem;
229 font-size: 0.875rem;
230 line-height: 1.5;
231 background: var(--surface);
232 color: var(--text);
233 transition: border-color 0.15s;
234 }
235 .form-input:focus {
236 outline: 2px solid var(--primary);
237 outline-offset: 2px;
238 border-color: var(--primary);
239 box-shadow: 0 0 0 3px var(--primary-light);
240 }
241 textarea.form-input { min-height: 100px; resize: vertical; }
242 select.form-input { appearance: auto; }
243
244 /* Action bar (bulk actions) */
245 .action-bar {
246 display: flex;
247 gap: 0.5rem;
248 align-items: center;
249 margin-top: 0.75rem;
250 padding-top: 0.75rem;
251 border-top: 1px solid var(--border);
252 font-size: 0.875rem;
253 color: var(--text-muted);
254 }
255
256 /* Search bar */
257 .search-bar {
258 display: flex;
259 gap: 0.75rem;
260 margin-bottom: 1rem;
261 align-items: center;
262 }
263 .search-bar input {
264 flex: 1;
265 padding: 0.5rem 0.75rem;
266 border: 1px solid var(--control-border);
267 border-radius: 0.375rem;
268 font-size: 0.875rem;
269 }
270 .search-bar input:focus {
271 outline: 2px solid var(--primary);
272 outline-offset: 2px;
273 border-color: var(--primary);
274 box-shadow: 0 0 0 3px var(--primary-light);
275 }
276
277 /* Pagination */
278 .pagination {
279 display: flex;
280 justify-content: space-between;
281 align-items: center;
282 margin-top: 1rem;
283 font-size: 0.875rem;
284 color: var(--text-muted);
285 }
286 .autumn-pager {
287 display: flex;
288 gap: 0.25rem;
289 }
290 .autumn-pager a, .autumn-pager span {
291 padding: 0.375rem 0.75rem;
292 border: 1px solid var(--border);
293 border-radius: 0.375rem;
294 font-size: 0.8125rem;
295 color: var(--text);
296 }
297 .autumn-pager a:hover { background: var(--bg); text-decoration: none; }
298 .autumn-pager .autumn-pager__current {
299 background: var(--primary);
300 color: var(--on-primary);
301 border-color: var(--primary);
302 }
303 .autumn-pager .autumn-pager__ellipsis { border: none; color: var(--text-muted); }
304 .autumn-pager .autumn-pager__disabled { color: var(--text-muted); opacity: 0.5; }
305
306 /* Dashboard stats */
307 .stats-grid {
308 display: grid;
309 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
310 gap: 1rem;
311 margin-bottom: 1.5rem;
312 }
313 .autumn-stat-card {
314 background: var(--surface);
315 border-radius: var(--radius);
316 box-shadow: var(--shadow);
317 padding: 1.25rem;
318 }
319 .autumn-stat-card__label { font-size: 0.8125rem; color: var(--text-muted); font-weight: 500; }
320 .autumn-stat-card__value { font-size: 1.75rem; font-weight: 700; margin-top: 0.25rem; }
321 .autumn-stat-card__link { font-size: 0.8125rem; margin-top: 0.375rem; }
322 .jobs-counter-grid {
323 display: grid;
324 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
325 gap: 0.75rem;
326 margin-bottom: 1rem;
327 }
328 .jobs-counter {
329 border: 1px solid var(--border);
330 border-radius: var(--radius);
331 padding: 0.875rem;
332 background: var(--bg);
333 }
334 .jobs-counter strong {
335 display: block;
336 font-size: 1.35rem;
337 line-height: 1.1;
338 margin-top: 0.2rem;
339 }
340 .job-error summary {
341 cursor: pointer;
342 color: var(--danger);
343 }
344 .job-error pre {
345 margin-top: 0.5rem;
346 white-space: pre-wrap;
347 word-break: break-word;
348 background: var(--danger-light);
349 border-radius: 0.375rem;
350 padding: 0.5rem;
351 max-width: 32rem;
352 }
353 .job-blocked {
354 color: var(--warning-text);
355 font-weight: 600;
356 }
357 .job-actions {
358 display: flex;
359 gap: 0.375rem;
360 flex-wrap: wrap;
361 }
362 .job-actions form { display: inline; }
363
364 /* Breadcrumbs */
365 .breadcrumbs {
366 font-size: 0.875rem;
367 color: var(--text-muted);
368 margin-bottom: 1rem;
369 }
370 .breadcrumbs a { color: var(--text-muted); }
371 .breadcrumbs a:hover { color: var(--primary); }
372 .breadcrumbs .sep { margin: 0 0.5rem; }
373
374 /* Detail view */
375 .detail-grid {
376 display: grid;
377 grid-template-columns: 160px 1fr;
378 gap: 0;
379 }
380 .detail-label {
381 padding: 0.75rem;
382 font-weight: 500;
383 color: var(--text-muted);
384 font-size: 0.875rem;
385 border-bottom: 1px solid var(--border);
386 background: var(--bg);
387 }
388 .detail-value {
389 padding: 0.75rem;
390 font-size: 0.875rem;
391 border-bottom: 1px solid var(--border);
392 word-break: break-word;
393 }
394
395 /* Responsive */
396 @media (max-width: 768px) {
397 .admin-sidebar { display: none; }
398 .admin-main { margin-left: 0; }
399 .stats-grid { grid-template-columns: 1fr 1fr; }
400 }
401 ";
402
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 pub theme: autumn_web::ui::theme::Theme,
588}
589
590#[derive(Clone, Copy)]
592pub struct ModelListView<'a> {
593 pub model_slug: &'a str,
594 pub model_name_plural: &'a str,
595 pub fields: &'a [AdminField],
596 pub actions: &'a [AdminAction],
597 pub result: &'a ListResult,
598 pub search_query: &'a str,
599 pub sort_by: Option<&'a str>,
600 pub sort_dir: SortDirection,
601 pub filters: &'a [(String, String)],
604 pub supports_csv_export: bool,
605 pub supports_csv_import: bool,
606}
607
608#[derive(Clone, Copy)]
610pub struct ModelDetailView<'a> {
611 pub model_slug: &'a str,
612 pub model_name: &'a str,
613 pub model_name_plural: &'a str,
614 pub fields: &'a [AdminField],
615 pub record: &'a Value,
616 pub record_display: &'a str,
617 pub id: i64,
621 pub has_history: bool,
622}
623
624#[derive(Clone, Copy)]
626pub struct ModelFormView<'a> {
627 pub model_slug: &'a str,
628 pub model_name: &'a str,
629 pub model_name_plural: &'a str,
630 pub fields: &'a [AdminField],
631 pub record: Option<&'a Value>,
632 pub id: Option<i64>,
635 pub raw_fields: &'a [&'a str],
639}
640
641pub fn admin_layout(
643 chrome: &AdminChrome<'_>,
644 active_slug: Option<&str>,
645 title: &str,
646 content: &Markup,
647) -> Markup {
648 let AdminChrome {
649 registry,
650 prefix,
651 actuator_prefix,
652 csrf_token,
653 csrf_token_header,
654 messages,
655 show_config,
656 impersonation,
657 theme,
658 ..
659 } = *chrome;
660 let jobs_href = format!("{prefix}/jobs");
666 let config_href = format!("{prefix}/config");
667 let current_path = match active_slug {
668 None => prefix.to_owned(),
669 Some(JOBS_NAV_SLUG) => jobs_href.clone(),
670 Some(RUNTIME_CONFIG_NAV_SLUG) => config_href.clone(),
671 Some(slug) => format!("{prefix}/{slug}"),
672 };
673
674 let mut nav_items = vec![NavItem::link(prefix, "Dashboard")];
675 if registry.model_count() > 0 {
676 nav_items.push(NavItem::section("Models"));
677 nav_items.extend(registry.iter().map(|(slug, model)| {
678 NavItem::link(format!("{prefix}/{slug}"), model.display_name_plural())
679 }));
680 }
681 nav_items.push(NavItem::section("System"));
682 nav_items.push(NavItem::link(jobs_href, "Jobs"));
683 if show_config {
684 nav_items.push(NavItem::link(config_href, "Runtime Config"));
685 }
686 nav_items.push(NavItem::plain_link(
687 format!("{actuator_prefix}/ui"),
688 "Actuator",
689 ));
690 let sidebar_nav = NavBarConfig::new()
691 .brand_html(html! { "🍂 Autumn Admin" }, None)
692 .items(nav_items)
693 .aria_label("Admin navigation")
694 .layout(NavBarLayout::Sidebar)
695 .class("admin-sidebar");
696
697 html! {
698 (DOCTYPE)
699 html lang="en" data-theme=[theme.data_theme()] {
700 head {
701 meta charset="utf-8";
702 meta name="viewport" content="width=device-width, initial-scale=1";
703 meta name="csrf-token" content=(csrf_token) data-header=(csrf_token_header);
709 title { (title) " — Autumn Admin" }
710 script src=(autumn_web::assets::asset_url("js/htmx.min.js")) {}
713 script src=(autumn_web::assets::asset_url("js/autumn-htmx-csrf.js")) {}
714 script src=(autumn_web::assets::asset_url("js/autumn-widgets.js")) defer {}
719 (crate::routes::ASSETS.script_tag("admin.js"))
721 style {
722 (PreEscaped(TOKENS_CSS))
723 (PreEscaped(FLASH_CSS))
724 (PreEscaped(ADMIN_CSS))
725 (PreEscaped(IMPERSONATION_BANNER_CSS))
726 }
727 }
728 body {
729 a href="#admin-main" class="admin-skip-link" { "Skip to main content" }
731 @if let Some(banner) = impersonation {
734 (impersonation_banner(banner))
735 }
736 div class="admin-layout" {
737 header role="banner" {
739 (nav_bar(¤t_path, &sidebar_nav))
740 }
741 main id="admin-main" class="admin-main" {
743 (flash_messages(messages))
744 (content)
745 }
746 }
747 }
748 }
749 }
750}
751
752fn csrf_hidden_input(csrf_token: &str, csrf_form_field: &str) -> Markup {
755 html! {
756 input type="hidden" name=(csrf_form_field) value=(csrf_token);
757 }
758}
759
760pub fn jobs_page(chrome: &AdminChrome<'_>, snapshot: &JobAdminSnapshot) -> Markup {
762 let AdminChrome {
763 prefix,
764 csrf_token,
765 csrf_form_field,
766 ..
767 } = *chrome;
768 let content = html! {
769 div class="breadcrumbs" {
770 a href=(prefix) { "Admin" }
771 span class="sep" { "›" }
772 span { "Jobs" }
773 }
774
775 h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 1rem;" {
776 "Jobs"
777 }
778
779 (jobs_counters(snapshot, prefix))
780
781 (job_list_card(
782 "Enqueued",
783 "Work waiting for a worker, including jobs waiting on a concurrency slot.",
784 &snapshot.enqueued,
785 "enqueued_page",
786 csrf_token,
787 csrf_form_field,
788 prefix,
789 ))
790 (job_list_card(
791 "Scheduled",
792 "Delayed one-shot jobs waiting for their due time. Cancel before they run.",
793 &snapshot.scheduled,
794 "scheduled_page",
795 csrf_token,
796 csrf_form_field,
797 prefix,
798 ))
799 (job_list_card(
800 "Running",
801 "Work currently executing in this runtime.",
802 &snapshot.running,
803 "running_page",
804 csrf_token,
805 csrf_form_field,
806 prefix,
807 ))
808 (job_list_card(
809 "Completed (last 24h)",
810 "Recently completed work retained by the bounded dashboard history.",
811 &snapshot.completed,
812 "completed_page",
813 csrf_token,
814 csrf_form_field,
815 prefix,
816 ))
817 (job_list_card(
818 "Failed (last 7d)",
819 "Terminal failures available for retry or discard.",
820 &snapshot.failed,
821 "failed_page",
822 csrf_token,
823 csrf_form_field,
824 prefix,
825 ))
826 (job_schedules_card(&snapshot.schedules))
827
828 p style="font-size: 0.8125rem; color: var(--text-muted); margin-top: 1rem;" {
829 "Default backend history is bounded to " (snapshot.bounded_history_limit)
830 " lifecycle entries; counter refreshes use bounded in-memory reads."
831 }
832 };
833
834 admin_layout(chrome, Some(JOBS_NAV_SLUG), "Jobs", &content)
835}
836
837pub fn jobs_counters(snapshot: &JobAdminSnapshot, prefix: &str) -> Markup {
839 html! {
840 div id="jobs-counters"
841 class="jobs-counter-grid"
842 hx-get={ (prefix) "/jobs/counters" }
843 hx-trigger="load, every 2s"
844 hx-swap="outerHTML" {
845 (job_counter("Enqueued", snapshot.enqueued.total))
846 (job_counter("Scheduled", snapshot.scheduled.total))
847 (job_counter("Running", snapshot.running.total))
848 (job_counter("Completed 24h", snapshot.completed.total))
849 (job_counter("Failed 7d", snapshot.failed.total))
850 }
851 }
852}
853
854fn job_counter(label: &str, value: u64) -> Markup {
855 html! {
856 div class="jobs-counter" {
857 span class="autumn-stat-card__label" { (label) }
858 strong { (value) }
859 }
860 }
861}
862
863fn job_list_card(
864 title: &str,
865 description: &str,
866 page: &JobAdminPage,
867 page_param: &str,
868 csrf_token: &str,
869 csrf_form_field: &str,
870 prefix: &str,
871) -> Markup {
872 let header_action = html! {
873 span style="font-size: 0.875rem; color: var(--text-muted);" {
874 (page.total) " total"
875 }
876 };
877 let title_markup = html! {
878 (title)
879 span style="display: block; font-size: 0.8125rem; color: var(--text-muted); margin-top: 0.25rem; font-weight: 400;" {
880 (description)
881 }
882 };
883 let body = html! {
884 div class="table-wrap" {
885 table {
886 thead {
887 tr {
888 th { "Job" }
889 th { "Enqueued At" }
890 th { "Started At" }
891 th { "Finished At" }
892 th { "Attempts" }
893 th { "Principal" }
894 th { "Correlation" }
895 th { "Last Error" }
896 th { "Actions" }
897 }
898 }
899 tbody {
900 @if page.records.is_empty() {
901 tr {
902 td colspan="9" style="text-align: center; padding: 1.5rem; color: var(--text-muted);" {
903 "No jobs."
904 }
905 }
906 }
907 @for record in &page.records {
908 (job_row(record, csrf_token, csrf_form_field, prefix))
909 }
910 }
911 }
912 }
913 (jobs_pagination(page, page_param, prefix))
914 };
915 card(
916 &body,
917 &CardConfig::new()
918 .title_html(title_markup)
919 .header_action(header_action),
920 )
921}
922
923fn job_row(
924 record: &JobAdminRecord,
925 csrf_token: &str,
926 csrf_form_field: &str,
927 prefix: &str,
928) -> Markup {
929 html! {
930 tr {
931 td {
932 strong { (record.name) }
933 div style="font-size: 0.75rem; color: var(--text-muted);" {
934 (record.status.label())
935 @if record.blocked_on_concurrency {
936 " · " span class="job-blocked" { "waiting on a concurrency slot" }
937 }
938 " · queue " (record.queue) " · " (record.id)
939 @if let Some(due) = record.scheduled_for.as_deref() {
940 " · due " (due)
941 }
942 }
943 }
944 td { (optional_text(record.enqueued_at.as_deref())) }
945 td { (optional_text(record.started_at.as_deref())) }
946 td { (optional_text(record.finished_at.as_deref())) }
947 td { (record.attempt) "/" (record.max_attempts) }
948 td { (optional_text(record.principal_id.as_deref())) }
949 td { (optional_text(record.correlation_id.as_deref())) }
950 td { (job_error(record)) }
951 td { (job_actions(record, csrf_token, csrf_form_field, prefix)) }
952 }
953 }
954}
955
956fn job_error(record: &JobAdminRecord) -> Markup {
957 let Some(error) = record.last_error.as_deref() else {
958 return html! { span style="color: var(--text-muted);" { "—" } };
959 };
960 if record.status == JobAdminStatus::Failed {
961 html! {
962 details class="job-error" {
963 summary { (truncate_display(error, 80)) }
964 pre { (error) }
965 }
966 }
967 } else {
968 html! { (truncate_display(error, 80)) }
969 }
970}
971
972fn job_actions(
973 record: &JobAdminRecord,
974 csrf_token: &str,
975 csrf_form_field: &str,
976 prefix: &str,
977) -> Markup {
978 html! {
979 div class="job-actions" {
980 @if record.status == JobAdminStatus::Failed {
981 (job_action_form(prefix, &record.id, "retry", "Retry", "btn btn-sm btn-primary", csrf_token, csrf_form_field))
982 (job_action_form(prefix, &record.id, "discard", "Discard", "btn btn-sm btn-danger", csrf_token, csrf_form_field))
983 } @else if record.status == JobAdminStatus::Enqueued || record.status == JobAdminStatus::Scheduled {
984 (job_action_form(prefix, &record.id, "cancel", "Cancel", "btn btn-sm btn-danger", csrf_token, csrf_form_field))
985 } @else {
986 span style="color: var(--text-muted);" { "—" }
987 }
988 }
989 }
990}
991
992fn job_action_form(
993 prefix: &str,
994 id: &str,
995 action: &str,
996 label: &str,
997 class_name: &str,
998 csrf_token: &str,
999 csrf_form_field: &str,
1000) -> Markup {
1001 html! {
1002 form method="post" action={ (prefix) "/jobs/" (id) "/" (action) } {
1003 (csrf_hidden_input(csrf_token, csrf_form_field))
1004 button type="submit" class=(class_name) {
1005 (label)
1006 }
1007 }
1008 }
1009}
1010
1011fn jobs_pagination(page: &JobAdminPage, page_param: &str, prefix: &str) -> Markup {
1012 if page.total_pages() <= 1 {
1013 return html! {};
1014 }
1015 let meta = page_meta(page.page, page.per_page, page.total, page.total_pages());
1016 let base = format!("{prefix}/jobs");
1017 let opts = PagerOptions::new(&base).page_param(page_param).window(1);
1018 html! {
1019 div class="pagination" {
1020 div {
1021 "Page " (page.page) " of " (page.total_pages())
1022 }
1023 (pagination_nav(&meta, &opts))
1024 }
1025 }
1026}
1027
1028fn job_schedules_card(schedules: &[JobScheduleSummary]) -> Markup {
1029 let body = html! {
1030 div class="table-wrap" {
1031 table {
1032 thead {
1033 tr {
1034 th { "Name" }
1035 th { "Schedule" }
1036 th { "Next Run At" }
1037 th { "Last Run Status" }
1038 }
1039 }
1040 tbody {
1041 @if schedules.is_empty() {
1042 tr {
1043 td colspan="4" style="text-align: center; padding: 1.5rem; color: var(--text-muted);" {
1044 "No scheduled tasks registered."
1045 }
1046 }
1047 }
1048 @for schedule in schedules {
1049 tr {
1050 td { (schedule.name) }
1051 td { (schedule.schedule) }
1052 td { (optional_text(schedule.next_run_at.as_deref())) }
1053 td { (optional_text(schedule.last_run_status.as_deref())) }
1054 }
1055 }
1056 }
1057 }
1058 }
1059 };
1060 card(&body, &CardConfig::new().title("Recurring Schedules"))
1061}
1062
1063fn optional_text(value: Option<&str>) -> Markup {
1064 value.filter(|value| !value.is_empty()).map_or_else(
1065 || html! { span style="color: var(--text-muted);" { "—" } },
1066 |value| html! { (value) },
1067 )
1068}
1069
1070pub fn dashboard_page(
1074 chrome: &AdminChrome<'_>,
1075 model_counts: &[(&str, &str, u64)], ) -> Markup {
1077 let AdminChrome {
1078 prefix,
1079 actuator_prefix,
1080 ..
1081 } = *chrome;
1082 let content = html! {
1083 h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem;" {
1084 "Dashboard"
1085 }
1086
1087 div class="stats-grid" {
1088 @for (slug, name, count) in model_counts {
1089 (stat_card(name, &count.to_string(), Some((&format!("{prefix}/{slug}"), "View all →"))))
1090 }
1091 }
1092
1093 ({
1095 let action = html! {
1096 a href={ (actuator_prefix) "/ui" } class="btn btn-sm" { "Full Dashboard →" }
1097 };
1098 let body = html! {
1099 div hx-get={ (actuator_prefix) "/ui/metrics" } hx-trigger="load, every 5s" {
1100 "Loading metrics…"
1101 }
1102 };
1103 card(&body, &CardConfig::new().title("System Health").header_action(action))
1104 })
1105 };
1106 admin_layout(chrome, None, "Dashboard", &content)
1107}
1108
1109#[allow(clippy::too_many_lines)]
1113pub fn model_list_page(chrome: &AdminChrome<'_>, view: &ModelListView<'_>) -> Markup {
1114 let AdminChrome {
1115 prefix,
1116 csrf_token,
1117 csrf_form_field,
1118 ..
1119 } = *chrome;
1120 let ModelListView {
1121 model_slug,
1122 model_name_plural,
1123 fields,
1124 actions,
1125 result,
1126 search_query,
1127 sort_by,
1128 sort_dir,
1129 filters,
1130 supports_csv_export,
1131 supports_csv_import,
1132 ..
1133 } = *view;
1134 let list_fields: Vec<_> = fields
1139 .iter()
1140 .filter(|f| {
1141 f.list_display && !matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden)
1142 })
1143 .collect();
1144 let search_enc = url_encode(search_query);
1145 let filters_enc = encode_filter_suffix(filters);
1148 let export_csv_url = {
1151 let mut params: Vec<String> = Vec::new();
1152 if !search_enc.is_empty() {
1153 params.push(format!("q={search_enc}"));
1154 }
1155 if let Some(sort) = sort_by {
1156 params.push(format!("sort={}", url_encode(sort)));
1157 params.push(format!("dir={}", sort_dir.as_str()));
1158 }
1159 for (k, v) in filters {
1160 params.push(format!("filter.{}={}", url_encode(k), url_encode(v)));
1161 }
1162 if params.is_empty() {
1163 format!("{prefix}/{model_slug}/export.csv")
1164 } else {
1165 format!("{prefix}/{model_slug}/export.csv?{}", params.join("&"))
1166 }
1167 };
1168
1169 let content = html! {
1170 div class="breadcrumbs" {
1172 a href=(prefix) { "Admin" }
1173 span class="sep" { "›" }
1174 span { (model_name_plural) }
1175 }
1176
1177 ({
1178 let title = html! {
1179 (model_name_plural)
1180 span style="font-weight: 400; color: var(--text-muted); margin-left: 0.5rem;" {
1181 "(" (result.total) ")"
1182 }
1183 };
1184 let header_action = html! {
1185 div style="display: flex; gap: 0.5rem; align-items: center;" {
1186 @if supports_csv_export {
1187 a href=(export_csv_url) class="btn btn-sm"
1188 title="Download all matching records as CSV" {
1189 "⬇ Download CSV"
1190 }
1191 }
1192 @if supports_csv_import {
1193 a href={ (prefix) "/" (model_slug) "/import" } class="btn btn-sm"
1194 title="Upload a CSV file to import records" {
1195 "⬆ Import CSV"
1196 }
1197 }
1198 a href={ (prefix) "/" (model_slug) "/new" } class="btn btn-primary" {
1199 "+ Add " (model_slug.trim_end_matches('s'))
1200 }
1201 }
1202 };
1203 let body = html! {
1204 form class="search-bar" method="get" {
1210 input type="search" name="q" placeholder="Search…"
1211 aria-label="Search records"
1212 value=(search_query)
1213 hx-get={ (prefix) "/" (model_slug) }
1214 hx-trigger="input changed delay:300ms"
1215 hx-include="closest form"
1216 hx-target="closest .autumn-card"
1217 hx-select=".autumn-card > *"
1218 hx-push-url="true" {}
1219 @for (k, v) in filters {
1220 input type="hidden" name={ "filter." (k) } value=(v);
1221 }
1222 }
1223
1224 form method="post" action={ (prefix) "/" (model_slug) "/actions" } {
1227 (csrf_hidden_input(csrf_token, csrf_form_field))
1228
1229 div class="table-wrap" {
1231 table {
1232 thead {
1233 tr {
1234 th class="checkbox-cell" {
1235 input type="checkbox" id="select-all" aria-label="Select all rows";
1237 }
1238 @for field in &list_fields {
1239 @let is_sorted = sort_by == Some(field.name);
1240 @let next_dir = if is_sorted { sort_dir.flipped() } else { SortDirection::Asc };
1241 th {
1242 @if field.sortable {
1243 a href={ (prefix) "/" (model_slug) "?sort=" (field.name) "&dir=" (next_dir.as_str())
1244 @if !search_enc.is_empty() { "&q=" (search_enc) }
1245 (filters_enc)
1246 }
1247 style="color: inherit; text-decoration: none;" {
1248 (field.label)
1249 @if is_sorted {
1250 span class="sort-icon" {
1251 @if matches!(sort_dir, SortDirection::Asc) { "▲" } @else { "▼" }
1252 }
1253 }
1254 }
1255 } @else {
1256 (field.label)
1257 }
1258 }
1259 }
1260 th { "Actions" }
1261 }
1262 }
1263 tbody {
1264 @if result.records.is_empty() {
1265 tr {
1266 td colspan=(list_fields.len() + 2)
1267 style="text-align: center; padding: 2rem; color: var(--text-muted);" {
1268 "No records found."
1269 }
1270 }
1271 }
1272 @for record in &result.records {
1273 @let row_id = record_id(record);
1274 tr {
1275 td class="checkbox-cell" {
1276 @if let Some(id) = row_id {
1280 input type="checkbox" class="row-check"
1281 aria-label="Select row"
1282 name="ids" value=(id);
1283 }
1284 }
1285 @for field in &list_fields {
1286 td { (render_cell_value(record, field)) }
1287 }
1288 td {
1289 @if let Some(id) = row_id {
1290 a href={ (prefix) "/" (model_slug) "/" (id) }
1291 class="btn btn-sm" { "View" }
1292 " "
1293 a href={ (prefix) "/" (model_slug) "/" (id) "/edit" }
1294 class="btn btn-sm" { "Edit" }
1295 } @else {
1296 span style="color: var(--text-muted); font-size: 0.75rem;" {
1298 "no id"
1299 }
1300 }
1301 }
1302 }
1303 }
1304 }
1305 }
1306 }
1307
1308 @if !actions.is_empty() {
1312 div class="action-bar" {
1313 label for="bulk-action" { "With selected:" }
1314 select name="action" id="bulk-action" class="form-input"
1315 style="width: auto; display: inline-block;" {
1316 @for a in actions {
1317 option value=(a.name) data-confirm=[a.confirm.then_some("1")] {
1318 (a.label)
1319 }
1320 }
1321 }
1322 button type="submit" class="btn" data-bulk-submit="1" {
1323 "Apply"
1324 }
1325 }
1326 }
1327
1328 } @if actions.iter().any(|a| a.confirm) {
1336 (modal(
1337 "admin-bulk-confirm",
1338 "Confirm bulk action",
1339 &html! { p data-bulk-confirm-detail {} },
1340 &ModalConfig::new().footer(html! {
1341 (modal_close_button("Cancel", "admin-bulk-confirm", Some("btn")))
1342 button type="button" class="btn btn-danger"
1343 command="close" commandfor="admin-bulk-confirm"
1344 data-modal-close="admin-bulk-confirm" data-bulk-confirm {
1345 "Confirm"
1346 }
1347 }),
1348 ))
1349 }
1350
1351 @if result.total_pages() > 1 {
1353 (render_pagination(result, model_slug, &search_enc, sort_by, sort_dir, &filters_enc, prefix))
1354 }
1355 };
1356 card(&body, &CardConfig::new().title_html(title).header_action(header_action))
1357 })
1358 };
1359 admin_layout(chrome, Some(model_slug), model_name_plural, &content)
1360}
1361
1362pub fn model_import_form_page(
1366 chrome: &AdminChrome<'_>,
1367 model_slug: &str,
1368 model_name_plural: &str,
1369) -> Markup {
1370 let AdminChrome {
1371 prefix,
1372 csrf_token,
1373 csrf_form_field,
1374 ..
1375 } = *chrome;
1376 let content = html! {
1377 div class="breadcrumbs" {
1378 a href=(prefix) { "Admin" }
1379 span class="sep" { "›" }
1380 a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
1381 span class="sep" { "›" }
1382 span { "Import CSV" }
1383 }
1384
1385 ({
1386 let title = html! { "Import " (model_name_plural) " from CSV" };
1387 let body = html! {
1388 p style="color: var(--text-muted); margin-bottom: 1rem;" {
1389 "Upload a CSV file with a header row. Column names must match the model's field names."
1390 }
1391
1392 form id="autumn-csv-import-form"
1393 method="post"
1394 action={ (prefix) "/" (model_slug) "/import?" (csrf_form_field) "=" (csrf_token) }
1395 enctype="multipart/form-data" {
1396
1397 (csrf_hidden_input(csrf_token, csrf_form_field))
1398
1399 div style="margin-bottom: 1rem;" {
1400 label for="csv-file" style="display: block; margin-bottom: 0.25rem; font-weight: 500;" {
1401 "CSV File"
1402 }
1403 input type="file" id="csv-file" name="file"
1404 accept=".csv,text/csv"
1405 required
1406 class="form-input" {}
1407 }
1408
1409 div style="margin-bottom: 1.5rem;" {
1410 label for="import-mode" style="display: block; margin-bottom: 0.25rem; font-weight: 500;" {
1411 "Import Mode"
1412 }
1413 select id="import-mode" name="mode" class="form-input"
1414 style="width: auto; display: inline-block;" {
1415 option value="insert" selected { "Insert (add as new records)" }
1416 option value="dry_run" { "Dry Run (validate only, no writes)" }
1417 }
1418 }
1419
1420 div style="display: flex; gap: 0.75rem; align-items: center;" {
1421 button type="submit" class="btn btn-primary" { "Upload and Import" }
1422 a href={ (prefix) "/" (model_slug) } class="btn" { "Cancel" }
1423 }
1424 }
1425
1426 div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border);" {
1427 h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.5rem;" {
1428 "Tips"
1429 }
1430 ul style="color: var(--text-muted); font-size: 0.875rem; padding-left: 1.25rem;" {
1431 li { "The first row must be a header row with column names." }
1432 li { "Column names must match the model's field names." }
1433 li { "Use Dry Run to preview the import and catch errors before writing." }
1434 li {
1435 "Download a template: "
1436 a href={ (prefix) "/" (model_slug) "/export.csv" } { "export.csv" }
1437 }
1438 }
1439 }
1440 };
1441 card(&body, &CardConfig::new().title_html(title))
1442 })
1443 };
1444
1445 admin_layout(chrome, Some(model_slug), model_name_plural, &content)
1446}
1447
1448pub fn model_import_result_page(
1450 chrome: &AdminChrome<'_>,
1451 model_slug: &str,
1452 model_name_plural: &str,
1453 report: &AdminImportReport,
1454 mode: CsvImportMode,
1455) -> Markup {
1456 let AdminChrome { prefix, .. } = *chrome;
1457 let mode_label = match mode {
1458 CsvImportMode::DryRun => "Dry Run",
1459 CsvImportMode::Insert => "Insert",
1460 };
1461 let total = report.inserted + report.updated + report.skipped + report.errors.len() as u64;
1462
1463 let content = html! {
1464 div class="breadcrumbs" {
1465 a href=(prefix) { "Admin" }
1466 span class="sep" { "›" }
1467 a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
1468 span class="sep" { "›" }
1469 span { "Import Result" }
1470 }
1471
1472 ({
1473 let title = html! { "Import Report — " (mode_label) };
1474 let body = html! {
1475 div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem;" {
1476 div style="text-align: center; padding: 1rem; background: var(--success-light); border-radius: 0.375rem;" {
1477 div style="font-size: 1.5rem; font-weight: 700; color: var(--success);" { (report.inserted) }
1478 div style="font-size: 0.75rem; color: var(--text-muted);" { "Inserted" }
1479 }
1480 div style="text-align: center; padding: 1rem; background: var(--primary-light); border-radius: 0.375rem;" {
1481 div style="font-size: 1.5rem; font-weight: 700; color: var(--primary);" { (report.updated) }
1482 div style="font-size: 0.75rem; color: var(--text-muted);" { "Updated" }
1483 }
1484 div style="text-align: center; padding: 1rem; background: var(--border); border-radius: 0.375rem;" {
1485 div style="font-size: 1.5rem; font-weight: 700;" { (report.skipped) }
1486 div style="font-size: 0.75rem; color: var(--text-muted);" { "Skipped" }
1487 }
1488 div style="text-align: center; padding: 1rem; background: var(--danger-light); border-radius: 0.375rem;" {
1489 div style="font-size: 1.5rem; font-weight: 700; color: var(--danger);" { (report.errors.len()) }
1490 div style="font-size: 0.75rem; color: var(--text-muted);" { "Errors" }
1491 }
1492 }
1493
1494 p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1.5rem;" {
1495 "Processed " (total) " data rows."
1496 @if matches!(mode, CsvImportMode::DryRun) {
1497 " (Dry run — no records were written.)"
1498 }
1499 }
1500
1501 @if !report.errors.is_empty() {
1502 h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem; color: var(--danger);" {
1503 "Row Errors"
1504 }
1505 div class="table-wrap" {
1506 table {
1507 thead {
1508 tr {
1509 th { "Line" }
1510 th { "Column" }
1511 th { "Message" }
1512 }
1513 }
1514 tbody {
1515 @for err in &report.errors {
1516 tr {
1517 td { (err.line) }
1518 td {
1519 @if let Some(col) = &err.column {
1520 code { (col) }
1521 } @else {
1522 span style="color: var(--text-muted);" { "—" }
1523 }
1524 }
1525 td { (err.message) }
1526 }
1527 }
1528 }
1529 }
1530 }
1531 }
1532
1533 div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;" {
1534 a href={ (prefix) "/" (model_slug) } class="btn btn-primary" { "Back to list" }
1535 a href={ (prefix) "/" (model_slug) "/import" } class="btn" { "Import another file" }
1536 }
1537 };
1538 card(&body, &CardConfig::new().title_html(title))
1539 })
1540 };
1541
1542 admin_layout(chrome, Some(model_slug), model_name_plural, &content)
1543}
1544
1545pub fn model_detail_page(chrome: &AdminChrome<'_>, view: &ModelDetailView<'_>) -> Markup {
1549 let AdminChrome {
1550 prefix,
1551 csrf_token,
1552 csrf_form_field,
1553 ..
1554 } = *chrome;
1555 let ModelDetailView {
1556 model_slug,
1557 model_name,
1558 model_name_plural,
1559 fields,
1560 record,
1561 record_display,
1562 id,
1563 has_history,
1564 ..
1565 } = *view;
1566 let content = html! {
1567 div class="breadcrumbs" {
1568 a href=(prefix) { "Admin" }
1569 span class="sep" { "›" }
1570 a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
1571 span class="sep" { "›" }
1572 span { (record_display) }
1573 }
1574
1575 ({
1576 let delete_url = format!("{prefix}/{model_slug}/{id}");
1577 let delete_dialog_id = format!("delete-confirm-{id}");
1578 let delete_title = format!("Delete this {model_name}?");
1579 let header_action = html! {
1580 div class="header-actions" {
1581 @if has_history {
1582 a href={ (prefix) "/" (model_slug) "/" (id) "/history" }
1583 class="btn btn-secondary" { "History" }
1584 " "
1585 }
1586 a href={ (prefix) "/" (model_slug) "/" (id) "/edit" }
1587 class="btn btn-primary" { "Edit" }
1588 " "
1589 (confirm_action(
1590 &delete_dialog_id,
1591 "Delete",
1592 &delete_url,
1593 Method::DELETE,
1594 csrf_token,
1595 &ConfirmActionConfig::new()
1596 .title(&delete_title)
1597 .message(html! { p { "This action cannot be undone." } })
1598 .trigger_class("btn btn-danger")
1599 .confirm_class("btn btn-danger")
1600 .csrf_field(csrf_form_field),
1601 ))
1602 }
1603 };
1604 let body = html! {
1605 div class="detail-grid" {
1606 @for field in fields {
1607 div class="detail-label" { (field.label) }
1608 div class="detail-value" {
1609 (render_detail_value(record, field))
1610 }
1611 }
1612 }
1613 };
1614 card(&body, &CardConfig::new().title(record_display).header_action(header_action))
1615 })
1616 };
1617 admin_layout(chrome, Some(model_slug), record_display, &content)
1618}
1619
1620pub fn model_form_page(chrome: &AdminChrome<'_>, view: &ModelFormView<'_>) -> Markup {
1624 let AdminChrome {
1625 prefix,
1626 csrf_token,
1627 csrf_form_field,
1628 ..
1629 } = *chrome;
1630 let ModelFormView {
1631 model_slug,
1632 model_name,
1633 model_name_plural,
1634 fields,
1635 record,
1636 id,
1637 raw_fields,
1638 ..
1639 } = *view;
1640 let is_edit = id.is_some();
1641 let title = if is_edit {
1642 format!("Edit {model_name}")
1643 } else {
1644 format!("New {model_name}")
1645 };
1646
1647 let editable_fields: Vec<_> = fields.iter().filter(|f| f.editable).collect();
1648
1649 let content = html! {
1650 div class="breadcrumbs" {
1651 a href=(prefix) { "Admin" }
1652 span class="sep" { "›" }
1653 a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
1654 span class="sep" { "›" }
1655 span { (title) }
1656 }
1657
1658 ({
1659 let body = html! {
1660 form method="post"
1661 action={
1662 @if let Some(id) = id {
1663 (prefix) "/" (model_slug) "/" (id)
1664 } @else {
1665 (prefix) "/" (model_slug)
1666 }
1667 } {
1668 (csrf_hidden_input(csrf_token, csrf_form_field))
1669
1670 @for field in &editable_fields {
1671 div class="form-group" {
1672 label class="form-label" for=(field.name) {
1673 (field.label)
1674 @if field.required && !(is_edit && field.create_only) {
1675 span class="required" { "*" }
1676 }
1677 }
1678 @if is_edit && field.create_only {
1679 (render_readonly_display(field, record))
1682 } @else {
1683 (render_form_widget(field, record, is_edit, raw_fields))
1684 }
1685 }
1686 }
1687
1688 div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;" {
1689 button type="submit" class="btn btn-primary" {
1690 @if is_edit { "Save Changes" } @else { "Create" }
1691 }
1692 a href={ (prefix) "/" (model_slug) } class="btn" {
1693 "Cancel"
1694 }
1695 }
1696 }
1697 };
1698 card(&body, &CardConfig::new().title(&title))
1699 })
1700 };
1701 admin_layout(chrome, Some(model_slug), &title, &content)
1702}
1703
1704#[allow(clippy::too_many_lines)]
1708pub fn config_page(chrome: &AdminChrome<'_>, entries: &[ConfigEntry]) -> Markup {
1709 let AdminChrome {
1710 prefix,
1711 csrf_token,
1712 csrf_form_field,
1713 ..
1714 } = *chrome;
1715 let content = html! {
1716 div class="breadcrumbs" {
1717 a href=(prefix) { "Admin" }
1718 span class="sep" { "›" }
1719 span { "Runtime Config" }
1720 }
1721
1722 h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem;" {
1723 "Runtime Config"
1724 }
1725 p style="color: var(--text-muted); margin-bottom: 1.5rem; font-size: 0.875rem;" {
1726 "Live-tunable operational knobs. Changes take effect immediately without a restart."
1727 }
1728
1729 @if entries.is_empty() {
1730 (card(&html! {
1731 p style="color: var(--text-muted); padding: 1rem;" {
1732 "No config keys have been registered. Declare keys with "
1733 code { "ConfigRegistry::define" }
1734 " and pass the service via "
1735 code { "AdminPlugin::with_runtime_config" }
1736 "."
1737 }
1738 }, &CardConfig::new()))
1739 } @else {
1740 (card(&html! {
1741 table class="table" {
1742 thead {
1743 tr {
1744 th { "Key" }
1745 th { "Type" }
1746 th { "Current Value" }
1747 th { "Default" }
1748 th { "Status" }
1749 th { "Actions" }
1750 }
1751 }
1752 tbody {
1753 @for entry in entries {
1754 tr {
1755 td {
1756 strong { (entry.name) }
1757 @if let Some(desc) = &entry.description {
1758 br;
1759 span style="color: var(--text-muted); font-size: 0.8125rem;" {
1760 (desc)
1761 }
1762 }
1763 }
1764 td { code style="font-size: 0.8125rem;" { (entry.value_type) } }
1765 td { code style="font-size: 0.8125rem;" { (entry.current.to_raw()) } }
1766 td {
1767 code style="font-size: 0.8125rem; color: var(--text-muted);" {
1768 (entry.default.to_raw())
1769 }
1770 }
1771 td {
1772 @if entry.is_overridden {
1773 span style="color: var(--warning-text); font-size: 0.8125rem; font-weight: 500;" {
1774 "overridden"
1775 }
1776 } @else {
1777 span style="color: var(--text-muted); font-size: 0.8125rem;" {
1778 "default"
1779 }
1780 }
1781 }
1782 td {
1783 div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;" {
1784 form method="post"
1785 action={ (prefix) "/config/" (entry.name) "/set" }
1786 style="display: flex; gap: 0.25rem; align-items: center;" {
1787 (csrf_hidden_input(csrf_token, csrf_form_field))
1788 input type="text" name="value"
1789 aria-label=(format!("Value for {}", entry.name))
1790 value=(entry.current.to_raw())
1791 style="width: 11rem; font-size: 0.8125rem; padding: 0.25rem 0.5rem; border: 1px solid var(--border); border-radius: 0.25rem;" {}
1792 button type="submit" class="btn btn-sm btn-primary" { "Save" }
1793 }
1794 @if entry.is_overridden {
1795 form method="post"
1796 action={ (prefix) "/config/" (entry.name) "/unset" } {
1797 (csrf_hidden_input(csrf_token, csrf_form_field))
1798 button type="submit" class="btn btn-sm" { "Reset" }
1799 }
1800 }
1801 a href={ (prefix) "/config/" (entry.name) "/history" }
1802 class="btn btn-sm" { "History" }
1803 }
1804 }
1805 }
1806 }
1807 }
1808 }
1809 }, &CardConfig::new()))
1810 }
1811 };
1812 let chrome = AdminChrome {
1814 show_config: true,
1815 ..*chrome
1816 };
1817 admin_layout(
1818 &chrome,
1819 Some(RUNTIME_CONFIG_NAV_SLUG),
1820 "Runtime Config",
1821 &content,
1822 )
1823}
1824
1825pub fn config_history_page(
1827 chrome: &AdminChrome<'_>,
1828 key: &str,
1829 history: &[ConfigChangeRecord],
1830) -> Markup {
1831 let AdminChrome { prefix, .. } = *chrome;
1832 let title = format!("History: {key}");
1833 let content = html! {
1834 div class="breadcrumbs" {
1835 a href=(prefix) { "Admin" }
1836 span class="sep" { "›" }
1837 a href={ (prefix) "/config" } { "Runtime Config" }
1838 span class="sep" { "›" }
1839 span { (key) }
1840 }
1841
1842 h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 1rem;" {
1843 "History: " (key)
1844 }
1845
1846 (card(&html! {
1847 @if history.is_empty() {
1848 p style="color: var(--text-muted); padding: 1rem;" {
1849 "No changes recorded for this key yet."
1850 }
1851 } @else {
1852 table class="table" {
1853 thead {
1854 tr {
1855 th { "Timestamp (UTC)" }
1856 th { "Actor" }
1857 th { "Old Value" }
1858 th { "New Value" }
1859 }
1860 }
1861 tbody {
1862 @for record in history {
1863 tr {
1864 td {
1865 code style="font-size: 0.8125rem;" {
1866 (format_timestamp(record.timestamp_secs))
1867 }
1868 }
1869 td { (record.actor.as_deref().unwrap_or("—")) }
1870 td {
1871 @match &record.old_value {
1872 Some(v) => {
1873 code style="font-size: 0.8125rem;" { (v.to_raw()) }
1874 }
1875 None => {
1876 span style="color: var(--text-muted);" { "—" }
1877 }
1878 }
1879 }
1880 td {
1881 @match &record.new_value {
1882 Some(v) => {
1883 code style="font-size: 0.8125rem;" { (v.to_raw()) }
1884 }
1885 None => {
1886 span style="color: var(--text-muted); font-style: italic;" {
1887 "reset to default"
1888 }
1889 }
1890 }
1891 }
1892 }
1893 }
1894 }
1895 }
1896 }
1897 }, &CardConfig::new()))
1898
1899 a href={ (prefix) "/config" } class="btn" style="margin-top: 1rem;" {
1900 "← Back to Runtime Config"
1901 }
1902 };
1903 let chrome = AdminChrome {
1905 show_config: true,
1906 ..*chrome
1907 };
1908 admin_layout(&chrome, Some(RUNTIME_CONFIG_NAV_SLUG), &title, &content)
1909}
1910
1911fn format_timestamp(ts: u64) -> String {
1912 use chrono::{DateTime, Utc};
1913 let secs = i64::try_from(ts).unwrap_or(i64::MAX);
1914 DateTime::from_timestamp(secs, 0).map_or_else(
1915 || ts.to_string(),
1916 |dt: DateTime<Utc>| dt.format("%Y-%m-%d %H:%M:%S").to_string(),
1917 )
1918}
1919
1920fn render_cell_value(record: &Value, field: &AdminField) -> Markup {
1924 if matches!(field.kind, AdminFieldKind::Password) {
1928 return html! { "••••••••" };
1929 }
1930 if field.encrypted && !field.encrypted_visible {
1936 return html! { span title="encrypted at rest" { "••••••••" } };
1937 }
1938 if ::autumn_web::confidential::is_confidential_column_name(field.name) {
1943 return html! { span title="sealed for its owner" { "••••••••" } };
1944 }
1945 let val = record.get(field.name);
1946 match val {
1947 None | Some(Value::Null) => html! {
1948 span style="color: var(--text-muted);" { "—" }
1949 },
1950 Some(Value::Bool(b)) => html! {
1951 @if *b {
1952 span style="color: var(--success-text);" { "✓" }
1953 } @else {
1954 span style="color: var(--text-muted);" { "✗" }
1955 }
1956 },
1957 Some(Value::String(s)) => html! { (truncate_display(s, 80)) },
1958 Some(v) => html! { (v) },
1959 }
1960}
1961
1962fn url_encode(s: &str) -> String {
1968 let mut out = String::with_capacity(s.len());
1969 for b in s.as_bytes() {
1970 match *b {
1971 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
1972 out.push(*b as char);
1973 }
1974 other => {
1975 use std::fmt::Write;
1976 let _ = write!(out, "%{other:02X}");
1977 }
1978 }
1979 }
1980 out
1981}
1982
1983fn encode_filter_suffix(filters: &[(String, String)]) -> String {
1988 let mut out = String::new();
1989 for (k, v) in filters {
1990 out.push_str("&filter.");
1991 out.push_str(&url_encode(k));
1992 out.push('=');
1993 out.push_str(&url_encode(v));
1994 }
1995 out
1996}
1997
1998fn truncate_display(s: &str, max_chars: usize) -> String {
2000 if s.chars().count() <= max_chars {
2001 return s.to_owned();
2002 }
2003 let keep = max_chars.saturating_sub(1);
2004 let mut out: String = s.chars().take(keep).collect();
2005 out.push('…');
2006 out
2007}
2008
2009fn normalize_date_input(s: &str) -> String {
2014 if s.is_empty() {
2015 return String::new();
2016 }
2017 if chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
2019 return s.to_owned();
2020 }
2021 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
2023 return dt.format("%Y-%m-%d").to_string();
2024 }
2025 if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
2027 return ndt.format("%Y-%m-%d").to_string();
2028 }
2029 s.to_owned()
2030}
2031
2032fn normalize_datetime_local_input(s: &str) -> String {
2047 if s.is_empty() {
2048 return String::new();
2049 }
2050 if chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M").is_ok() {
2052 return s.to_owned();
2053 }
2054 if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
2055 return ndt.format("%Y-%m-%dT%H:%M").to_string();
2056 }
2057 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
2061 return dt.naive_local().format("%Y-%m-%dT%H:%M").to_string();
2062 }
2063 s.to_owned()
2064}
2065
2066fn render_detail_value(record: &Value, field: &AdminField) -> Markup {
2068 if field.encrypted && !field.encrypted_visible {
2071 return html! { span title="encrypted at rest" { "••••••••" } };
2072 }
2073 if ::autumn_web::confidential::is_confidential_column_name(field.name) {
2078 return html! { span title="sealed for its owner" { "••••••••" } };
2079 }
2080 let val = record.get(field.name);
2081 match val {
2082 None | Some(Value::Null) => html! {
2083 span style="color: var(--text-muted);" { "—" }
2084 },
2085 Some(Value::Bool(b)) => html! {
2086 @if *b { "Yes" } @else { "No" }
2087 },
2088 Some(Value::String(s)) => {
2089 if matches!(field.kind, AdminFieldKind::Password) {
2090 html! { "••••••••" }
2091 } else if matches!(field.kind, AdminFieldKind::TextArea | AdminFieldKind::Json) {
2092 html! {
2093 pre style="white-space: pre-wrap; font-size: 0.8125rem; background: var(--bg); padding: 0.75rem; border-radius: 0.375rem;" {
2094 (s)
2095 }
2096 }
2097 } else {
2098 html! { (s) }
2099 }
2100 }
2101 Some(v) => html! {
2105 pre style="white-space: pre-wrap; font-size: 0.8125rem; background: var(--bg); padding: 0.75rem; border-radius: 0.375rem;" {
2106 (serde_json::to_string_pretty(v).unwrap_or_default())
2107 }
2108 },
2109 }
2110}
2111
2112fn render_readonly_display(field: &AdminField, record: Option<&Value>) -> Markup {
2117 if ::autumn_web::confidential::is_confidential_column_name(field.name) {
2121 return html! {
2122 p class="form-static-value" style="margin: 0; padding: 0.375rem 0; color: var(--text-muted);" {
2123 span title="sealed for its owner" { "••••••••" }
2124 }
2125 small class="form-help" style="color: var(--text-muted);" {
2126 "This field cannot be changed after creation."
2127 }
2128 };
2129 }
2130 let value = record
2131 .and_then(|r| r.get(field.name))
2132 .map(|v| match v {
2133 Value::String(s) => s.clone(),
2134 other => other.to_string(),
2135 })
2136 .unwrap_or_default();
2137 html! {
2138 p class="form-static-value" style="margin: 0; padding: 0.375rem 0; color: var(--text-muted);" {
2139 (value)
2140 }
2141 small class="form-help" style="color: var(--text-muted);" {
2142 "This field cannot be changed after creation."
2143 }
2144 }
2145}
2146
2147#[allow(clippy::too_many_lines)]
2162fn render_form_widget(
2163 field: &AdminField,
2164 record: Option<&Value>,
2165 is_edit: bool,
2166 raw_fields: &[&str],
2167) -> Markup {
2168 if ::autumn_web::confidential::is_confidential_column_name(field.name) {
2179 return html! {
2180 input type="text" class="form-input" value="••••••••" disabled
2181 title="Sealed for its owner — the server cannot read or write it";
2182 };
2183 }
2184 if field.encrypted && is_edit {
2185 return html! {
2186 input type="text" class="form-input" value="••••••••" disabled
2187 title="Encrypted at rest — managed outside the admin";
2188 };
2189 }
2190 let current_value = if field.encrypted {
2198 Value::Null
2199 } else {
2200 record
2201 .and_then(|r| r.get(field.name))
2202 .cloned()
2203 .unwrap_or(Value::Null)
2204 };
2205 let str_val = match ¤t_value {
2206 Value::String(s) => s.clone(),
2207 Value::Null => String::new(),
2208 v => v.to_string(),
2209 };
2210
2211 match &field.kind {
2212 AdminFieldKind::Text => html! {
2213 input type="text" class="form-input" name=(field.name) id=(field.name)
2214 value=(str_val)
2215 required[field.required];
2216 },
2217 AdminFieldKind::TextArea => html! {
2218 textarea class="form-input" name=(field.name) id=(field.name)
2219 required[field.required] {
2220 (str_val)
2221 }
2222 },
2223 AdminFieldKind::Integer => html! {
2224 input type="number" class="form-input" name=(field.name) id=(field.name)
2225 value=(str_val) step="1"
2226 required[field.required];
2227 },
2228 AdminFieldKind::Float => html! {
2229 input type="number" class="form-input" name=(field.name) id=(field.name)
2230 value=(str_val) step="any"
2231 required[field.required];
2232 },
2233 AdminFieldKind::Boolean => {
2234 let checked = matches!(current_value, Value::Bool(true));
2235 html! {
2236 input type="hidden" name=(field.name) value="false";
2237 input type="checkbox" name=(field.name) id=(field.name)
2238 value="true" checked[checked]
2239 style="width: auto;";
2240 }
2241 }
2242 AdminFieldKind::Date => {
2243 let v = normalize_date_input(&str_val);
2244 html! {
2245 input type="date" class="form-input" name=(field.name) id=(field.name)
2246 value=(v)
2247 required[field.required];
2248 }
2249 }
2250 AdminFieldKind::DateTime => {
2251 let v = normalize_datetime_local_input(&str_val);
2252 html! {
2253 input type="datetime-local" class="form-input" name=(field.name) id=(field.name)
2254 value=(v)
2255 required[field.required];
2256 }
2257 }
2258 AdminFieldKind::Select(options) => html! {
2259 select class="form-input" name=(field.name) id=(field.name)
2260 required[field.required] {
2261 option value="" { "— Select —" }
2262 @for opt in options {
2263 option value=(opt.value)
2264 selected[str_val == opt.value] {
2265 (opt.label)
2266 }
2267 }
2268 }
2269 },
2270 AdminFieldKind::Hidden => html! {
2271 input type="hidden" name=(field.name) value=(str_val);
2272 },
2273 AdminFieldKind::Password => html! {
2274 input type="password" class="form-input" name=(field.name) id=(field.name)
2275 placeholder="Leave blank to keep current"
2276 autocomplete="new-password";
2277 },
2278 AdminFieldKind::Json => {
2279 let json_val = if raw_fields.contains(&field.name) || field.encrypted {
2309 str_val
2318 } else {
2319 match record.and_then(|r| r.get(field.name)) {
2320 None => String::new(),
2321 Some(Value::Null) if !field.required => String::new(),
2322 Some(v) => v.to_string(),
2323 }
2324 };
2325 html! {
2326 textarea class="form-input" name=(field.name) id=(field.name)
2327 style="font-family: monospace; min-height: 150px;"
2328 required[field.required] {
2329 (json_val)
2330 }
2331 }
2332 }
2333 }
2334}
2335
2336fn render_pagination(
2342 result: &ListResult,
2343 model_slug: &str,
2344 search_enc: &str,
2345 sort_by: Option<&str>,
2346 sort_dir: SortDirection,
2347 filters_enc: &str,
2348 prefix: &str,
2349) -> Markup {
2350 let current = result.page.max(1);
2351
2352 let query = {
2355 let mut s = String::new();
2356 if !search_enc.is_empty() {
2357 s.push_str("q=");
2358 s.push_str(search_enc);
2359 }
2360 if let Some(sort) = sort_by {
2361 if !s.is_empty() {
2362 s.push('&');
2363 }
2364 s.push_str("sort=");
2365 s.push_str(&url_encode(sort));
2366 s.push_str("&dir=");
2367 s.push_str(sort_dir.as_str());
2368 }
2369 if !filters_enc.is_empty() {
2370 let trimmed = filters_enc.strip_prefix('&').unwrap_or(filters_enc);
2372 if !s.is_empty() {
2373 s.push('&');
2374 }
2375 s.push_str(trimmed);
2376 }
2377 s
2378 };
2379
2380 let start = if result.total == 0 {
2381 0
2382 } else {
2383 result
2384 .per_page
2385 .saturating_mul(current.saturating_sub(1))
2386 .saturating_add(1)
2387 };
2388 let end = start
2389 .saturating_add(result.per_page)
2390 .saturating_sub(1)
2391 .min(result.total);
2392
2393 let meta = page_meta(current, result.per_page, result.total, result.total_pages());
2394 let base = format!("{prefix}/{model_slug}");
2395 let opts = PagerOptions::new(&base)
2396 .query(&query)
2397 .window(1)
2398 .prev_label("← Prev")
2399 .next_label("Next →");
2400
2401 html! {
2402 div class="pagination" {
2403 span {
2404 "Showing " (start) "–" (end) " of " (result.total)
2405 }
2406 (pagination_nav(&meta, &opts))
2407 }
2408 }
2409}
2410
2411fn page_meta(page: u64, per_page: u64, total: u64, total_pages: u64) -> Page<()> {
2412 let to_u32 = |v: u64| u32::try_from(v).unwrap_or(u32::MAX);
2413 Page::from_raw(to_u32(page), to_u32(per_page), total, to_u32(total_pages))
2414}
2415
2416#[allow(clippy::too_many_lines)]
2423pub fn model_history_page(
2424 chrome: &AdminChrome<'_>,
2425 model_slug: &str,
2426 model_name: &str,
2427 model_name_plural: &str,
2428 record_id_val: i64,
2429 history: &AdminHistoryPage,
2430) -> Markup {
2431 let AdminChrome { prefix, .. } = *chrome;
2432 let record_display = format!("{model_name} #{record_id_val}");
2433 let history_page_href = |page: u64| {
2434 format!(
2435 "{prefix}/{model_slug}/{record_id_val}/history?page={page}&per_page={}",
2436 history.per_page
2437 )
2438 };
2439 let content = html! {
2440 div class="breadcrumbs" {
2441 a href=(prefix) { "Admin" }
2442 span class="sep" { "›" }
2443 a href={ (prefix) "/" (model_slug) } { (model_name_plural) }
2444 span class="sep" { "›" }
2445 a href={ (prefix) "/" (model_slug) "/" (record_id_val) } { (record_display) }
2446 span class="sep" { "›" }
2447 span { "History" }
2448 }
2449
2450 ({
2451 let header_action = html! { small { " " (history.total) " entries" } };
2452 let body = html! {
2453 @if history.entries.is_empty() {
2454 p class="text-muted" style="padding:1rem" { "No history entries yet." }
2455 } @else {
2456 table class="admin-table" {
2457 thead {
2458 tr {
2459 th { "#" }
2460 th { "Operation" }
2461 th { "Actor" }
2462 th { "Request ID" }
2463 th { "Changes" }
2464 th { "Recorded At" }
2465 }
2466 }
2467 tbody {
2468 @for entry in &history.entries {
2469 tr {
2470 td { (entry.id) }
2471 td {
2472 span class={ "badge badge-" (entry.op) } { (entry.op) }
2473 }
2474 td { code { (entry.actor) } }
2475 td {
2476 @if let Some(ref req_id) = entry.request_id {
2477 code class="text-muted" { (req_id) }
2478 } @else {
2479 span class="text-muted" { "—" }
2480 }
2481 }
2482 td {
2483 @if entry.changes.is_empty() {
2484 span class="text-muted" { "no changes" }
2485 } @else {
2486 details {
2487 summary { (entry.changes.len()) " column(s)" }
2488 ul class="change-list" {
2489 @for change in &entry.changes {
2490 li {
2491 @if let Some(col) = change.get("column").and_then(Value::as_str) {
2492 code { (col) }
2493 }
2494 @if change.get("sensitive").and_then(Value::as_bool).unwrap_or(false) {
2495 span class="badge-sensitive" { " [sensitive]" }
2496 } @else {
2497 " "
2498 span class="text-muted" { "before: " }
2499 @if let Some(before) = change.get("before") {
2500 code { (before) }
2501 } @else {
2502 em { "null" }
2503 }
2504 " → "
2505 span class="text-muted" { "after: " }
2506 @if let Some(after) = change.get("after") {
2507 code { (after) }
2508 } @else {
2509 em { "null" }
2510 }
2511 }
2512 }
2513 }
2514 }
2515 }
2516 }
2517 }
2518 td {
2519 time datetime=(entry.recorded_at.to_rfc3339()) {
2520 (entry.recorded_at.format("%Y-%m-%d %H:%M:%S UTC"))
2521 }
2522 }
2523 }
2524 }
2525 }
2526 }
2527
2528 @if history.total_pages() > 1 {
2529 div class="pagination" {
2530 @if history.page > 1 {
2531 a href=(history_page_href(history.page - 1))
2532 class="btn btn-secondary btn-sm" { "← Prev" }
2533 }
2534 span { " Page " (history.page) " of " (history.total_pages()) " " }
2535 @if history.has_next_page() {
2536 a href=(history_page_href(history.page + 1))
2537 class="btn btn-secondary btn-sm" { "Next →" }
2538 }
2539 }
2540 }
2541 }
2542 };
2543 card(&body, &CardConfig::new().title("Version History").header_action(header_action))
2544 })
2545 };
2546 let chrome = AdminChrome {
2548 csrf_token: "",
2549 messages: &[],
2550 ..*chrome
2551 };
2552 admin_layout(&chrome, Some(model_slug), &record_display, &content)
2553}
2554
2555#[cfg(test)]
2556mod tests {
2557 use super::*;
2558
2559 fn test_chrome(registry: &AdminRegistry) -> AdminChrome<'_> {
2561 AdminChrome {
2562 registry,
2563 prefix: "/admin",
2564 actuator_prefix: "/actuator",
2565 csrf_token: "t",
2566 csrf_form_field: "_csrf",
2567 csrf_token_header: "X-CSRF-Token",
2568 messages: &[],
2569 show_config: false,
2570 impersonation: None,
2571 theme: autumn_web::ui::theme::Theme::System,
2572 }
2573 }
2574
2575 fn hex_channel(hex: &str, i: usize) -> f64 {
2578 f64::from(u8::from_str_radix(&hex[1 + i * 2..3 + i * 2], 16).unwrap()) / 255.0
2579 }
2580
2581 fn relative_luminance(hex: &str) -> f64 {
2582 let f = |c: f64| {
2583 if c <= 0.039_28 {
2584 c / 12.92
2585 } else {
2586 ((c + 0.055) / 1.055).powf(2.4)
2587 }
2588 };
2589 0.0722f64.mul_add(
2590 f(hex_channel(hex, 2)),
2591 0.2126f64.mul_add(f(hex_channel(hex, 0)), 0.7152 * f(hex_channel(hex, 1))),
2592 )
2593 }
2594
2595 fn contrast_ratio(a: &str, b: &str) -> f64 {
2596 let (la, lb) = (relative_luminance(a), relative_luminance(b));
2597 let (lighter, darker) = if la >= lb { (la, lb) } else { (lb, la) };
2598 (lighter + 0.05) / (darker + 0.05)
2599 }
2600
2601 #[test]
2621 fn form_and_search_input_focus_keeps_a_visible_outline() {
2622 assert!(
2623 !ADMIN_CSS.contains("outline: none"),
2624 "a form/search input :focus rule dropped its outline with no \
2625 equal-or-better replacement — box-shadow/border-color alone are \
2626 stripped under forced-colors mode, leaving keyboard users with \
2627 no visible focus indicator: {ADMIN_CSS}"
2628 );
2629 for selector in [".form-input:focus", ".search-bar input:focus"] {
2630 let start = ADMIN_CSS
2631 .find(selector)
2632 .unwrap_or_else(|| panic!("missing `{selector}` rule in ADMIN_CSS"));
2633 let block_end = ADMIN_CSS[start..]
2634 .find('}')
2635 .map_or(ADMIN_CSS.len(), |i| start + i);
2636 let block = &ADMIN_CSS[start..block_end];
2637 assert!(
2638 block.contains("outline: 2px solid var(--primary)"),
2639 "`{selector}` must keep a visible outline: {block}"
2640 );
2641 }
2642 }
2643
2644 fn core_color(name: &str, dark: bool) -> String {
2659 let css = autumn_web::ui::tokens::TOKENS_CSS;
2660 let at = css
2661 .find(&format!("{name}: light-dark("))
2662 .unwrap_or_else(|| panic!("{name} is not a light-dark() core token"));
2663 let args = &css[at + name.len() + ": light-dark(".len()..];
2664 let args = &args[..args.find(')').unwrap()];
2665 let (light, dark_value) = args.split_once(',').unwrap();
2666 (if dark { dark_value } else { light }).trim().to_owned()
2667 }
2668
2669 #[test]
2670 fn raw_warning_and_success_tokens_fail_wcag_aa_text_contrast_on_surface() {
2671 let surface = core_color("--surface", false);
2674 for token in ["--warning", "--success"] {
2675 assert!(
2676 contrast_ratio(&core_color(token, false), &surface) < 4.5,
2677 "if this now passes, {token} changed and the -text variant may be redundant"
2678 );
2679 }
2680 }
2681
2682 #[test]
2683 fn text_safe_warning_and_success_tokens_meet_wcag_aa_contrast_on_surface() {
2684 for dark in [false, true] {
2685 let surface = core_color("--surface", dark);
2686 for token in ["--warning-text", "--success-text", "--text", "--text-muted"] {
2687 let ratio = contrast_ratio(&core_color(token, dark), &surface);
2688 assert!(
2689 ratio >= 4.5,
2690 "{token} on --surface (dark={dark}): {ratio:.2}"
2691 );
2692 }
2693 for (fg, bg) in [("--on-primary", "--primary"), ("--on-danger", "--danger")] {
2694 let ratio = contrast_ratio(&core_color(fg, dark), &core_color(bg, dark));
2695 assert!(ratio >= 4.5, "{fg} on {bg} (dark={dark}): {ratio:.2}");
2696 }
2697 }
2698 }
2699
2700 #[test]
2702 fn admin_layout_embeds_the_core_tokens() {
2703 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
2704 assert!(
2705 !manifest_dir.join("src/tokens.css").exists(),
2706 "delete the private copy; use autumn_web::ui::tokens::TOKENS_CSS"
2707 );
2708 let r = dummy_registry();
2709 let html = dashboard_page(&test_chrome(&r), &[]).into_string();
2710 assert!(
2711 html.contains(autumn_web::ui::tokens::TOKENS_CSS),
2712 "the admin layout must embed the core tokens"
2713 );
2714 }
2715
2716 #[test]
2719 fn admin_layout_renders_the_stored_theme() {
2720 use autumn_web::ui::theme::Theme;
2721 let r = dummy_registry();
2722 let dark = dashboard_page(
2723 &AdminChrome {
2724 theme: Theme::Dark,
2725 ..test_chrome(&r)
2726 },
2727 &[],
2728 )
2729 .into_string();
2730 assert!(
2731 dark.contains(r#"<html lang="en" data-theme="dark">"#),
2732 "{dark}"
2733 );
2734 let system = dashboard_page(&test_chrome(&r), &[]).into_string();
2735 assert!(system.contains(r#"<html lang="en">"#), "{system}");
2736 }
2737
2738 #[test]
2741 fn admin_inputs_use_the_control_border() {
2742 for selector in [".form-input {", ".search-bar input {"] {
2743 let start = ADMIN_CSS.find(selector).unwrap();
2744 let end = start + ADMIN_CSS[start..].find('}').unwrap();
2745 assert!(
2746 ADMIN_CSS[start..end].contains("border: 1px solid var(--control-border);"),
2747 "{selector}"
2748 );
2749 }
2750 }
2751
2752 #[test]
2755 fn admin_css_has_no_white_text_on_a_token_fill() {
2756 for rule in ADMIN_CSS.split('}') {
2757 let white = ["color: #fff;", "color: white;"]
2758 .iter()
2759 .any(|w| rule.contains(w));
2760 let token_fill = rule
2761 .lines()
2762 .any(|l| l.trim().starts_with("background") && l.contains("var(--"));
2763 assert!(
2764 !(white && token_fill),
2765 "white text on a token fill:{rule}}}"
2766 );
2767 }
2768 }
2769
2770 #[test]
2771 fn config_page_overridden_status_uses_text_safe_warning_token() {
2772 use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
2773
2774 let r = dummy_registry();
2775 let entries = vec![ConfigEntry {
2776 name: "rate_limit".to_owned(),
2777 value_type: ConfigValueType::Int,
2778 current: ConfigValue::Int(200),
2779 default: ConfigValue::Int(100),
2780 is_overridden: true,
2781 description: None,
2782 }];
2783 let html = config_page(
2784 &AdminChrome {
2785 csrf_token: "tok",
2786 ..test_chrome(&r)
2787 },
2788 &entries,
2789 )
2790 .into_string();
2791 assert!(
2792 html.contains("color: var(--warning-text)"),
2793 "overridden status must use the text-safe warning token, not raw --warning: {html}"
2794 );
2795 assert!(!html.contains("color: var(--warning);"), "{html}");
2796 }
2797
2798 #[test]
2799 fn boolean_true_cell_uses_text_safe_success_token() {
2800 let record = serde_json::json!({ "active": true });
2801 let field = AdminField::new("active", AdminFieldKind::Boolean);
2802 let cell = render_cell_value(&record, &field).into_string();
2803 assert!(
2804 cell.contains("color: var(--success-text)"),
2805 "boolean-true cell must use the text-safe success token, not raw --success: {cell}"
2806 );
2807 assert!(!cell.contains("color: var(--success);"), "{cell}");
2808 }
2809
2810 #[test]
2815 fn encrypted_columns_are_redacted_in_admin_views() {
2816 let record = serde_json::json!({ "id": 1, "ssn": "123-45-6789" });
2817 let field = AdminField::new("ssn", AdminFieldKind::Text).encrypted();
2818 let cell = render_cell_value(&record, &field).into_string();
2819 let detail = render_detail_value(&record, &field).into_string();
2820 assert!(
2821 !cell.contains("123-45-6789"),
2822 "list cell must redact: {cell}"
2823 );
2824 assert!(
2825 !detail.contains("123-45-6789"),
2826 "detail must redact: {detail}"
2827 );
2828 assert!(cell.contains("••••••••"));
2829 assert!(detail.contains("••••••••"));
2830 }
2831
2832 autumn_web::reexports::inventory::submit! {
2837 autumn_web::confidential::ConfidentialColumnDescriptor {
2838 model: "AdminSealedNote",
2839 table: "admin_sealed_notes",
2840 column: "admin_sealed_body",
2841 blind_index: ::core::option::Option::Some("admin_sealed_body_bidx"),
2842 }
2843 }
2844
2845 #[test]
2848 fn confidential_columns_are_masked_across_admin_views() {
2849 let record = serde_json::json!({
2850 "id": 1,
2851 "admin_sealed_body": "z0BAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
2852 "admin_sealed_body_bidx": "0123456789abcdef0123456789abcdef",
2853 });
2854 for name in ["admin_sealed_body", "admin_sealed_body_bidx"] {
2855 let field = AdminField::new(name, AdminFieldKind::Text);
2856 let value = record.get(name).and_then(Value::as_str).unwrap();
2857 for (what, rendered) in [
2858 (
2859 "list cell",
2860 render_cell_value(&record, &field).into_string(),
2861 ),
2862 ("detail", render_detail_value(&record, &field).into_string()),
2863 (
2864 "form widget",
2865 render_form_widget(&field, Some(&record), true, &[]).into_string(),
2866 ),
2867 ] {
2868 assert!(
2869 !rendered.contains(value),
2870 "{what} leaked `{name}`: {rendered}"
2871 );
2872 assert!(rendered.contains("••••••••"), "{what}: {rendered}");
2873 }
2874 }
2875 }
2876
2877 #[test]
2880 fn a_create_only_confidential_column_is_masked_on_the_edit_form() {
2881 let record = serde_json::json!({
2882 "id": 1,
2883 "admin_sealed_body": "z0BAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
2884 });
2885 let field = AdminField::new("admin_sealed_body", AdminFieldKind::Text);
2886 let rendered = render_readonly_display(&field, Some(&record)).into_string();
2887 assert!(
2888 !rendered.contains("z0BAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
2889 "the read-only display leaked the envelope: {rendered}"
2890 );
2891 assert!(rendered.contains("••••••••"), "{rendered}");
2892 }
2893
2894 #[test]
2895 fn admin_visible_encrypted_column_renders_plaintext_in_views() {
2896 let record = serde_json::json!({ "id": 1, "audit_note": "visible-note" });
2899 let field = AdminField::new("audit_note", AdminFieldKind::Text).encrypted_visible();
2900 let cell = render_cell_value(&record, &field).into_string();
2901 let detail = render_detail_value(&record, &field).into_string();
2902 assert!(cell.contains("visible-note"), "admin_visible cell: {cell}");
2903 assert!(
2904 detail.contains("visible-note"),
2905 "admin_visible detail: {detail}"
2906 );
2907 }
2908
2909 #[test]
2910 fn edit_form_never_prefills_encrypted_plaintext() {
2911 let record = serde_json::json!({ "ssn": "123-45-6789", "audit_note": "visible-note" });
2914 for field in [
2915 AdminField::new("ssn", AdminFieldKind::Text).encrypted(),
2916 AdminField::new("audit_note", AdminFieldKind::Text).encrypted_visible(),
2917 ] {
2918 let col = field.name;
2919 let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
2920 assert!(
2921 !form.contains("123-45-6789") && !form.contains("visible-note"),
2922 "edit form must not pre-fill encrypted plaintext for {col}: {form}"
2923 );
2924 assert!(form.contains("disabled"), "edit control disabled: {form}");
2927 assert!(
2928 !form.contains("name="),
2929 "edit control must not submit: {form}"
2930 );
2931 }
2932 }
2933
2934 #[test]
2935 fn create_form_allows_setting_initial_encrypted_value() {
2936 let field = AdminField::new("ssn", AdminFieldKind::Text).encrypted();
2941 let form = render_form_widget(&field, None, false, &[]).into_string();
2942 assert!(
2943 form.contains("name=\"ssn\""),
2944 "create control must submit the value: {form}"
2945 );
2946 assert!(
2947 !form.contains("disabled"),
2948 "create control editable: {form}"
2949 );
2950 assert!(
2951 !form.contains("••••••••"),
2952 "create control is an empty input, not the redaction mask: {form}"
2953 );
2954 }
2955
2956 #[test]
2957 fn create_failure_redisplay_never_echoes_encrypted_plaintext() {
2958 let record = serde_json::json!({
2966 "ssn": "123-45-6789",
2967 "secret_config": {"token": "sk-live-999"},
2968 });
2969 for field in [
2970 AdminField::new("ssn", AdminFieldKind::Text).encrypted(),
2971 AdminField::new("secret_config", AdminFieldKind::Json).encrypted(),
2972 ] {
2973 let name = field.name;
2974 let form = render_form_widget(&field, Some(&record), false, &[]).into_string();
2975 assert!(
2976 form.contains(&format!("name=\"{name}\"")),
2977 "create control must stay submittable for {name}: {form}"
2978 );
2979 assert!(
2980 !form.contains("123-45-6789") && !form.contains("sk-live-999"),
2981 "encrypted {name} must not echo the submitted plaintext on a create failure: {form}"
2982 );
2983 }
2984 }
2985
2986 #[test]
2987 fn json_edit_form_prefills_a_stored_string_scalar_with_its_quotes() {
2988 let field = AdminField::new("config", AdminFieldKind::Json);
2994
2995 let record = serde_json::json!({ "config": "hello" });
2996 let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
2997 assert!(
2998 form.contains(""hello"") || form.contains("\"hello\""),
2999 "stored JSON string must render with its quotes intact: {form}"
3000 );
3001
3002 let record = serde_json::json!({ "config": "true" });
3005 let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
3006 assert!(
3007 form.contains(""true"") || form.contains("\"true\""),
3008 "a stored JSON string \"true\" must not render as the bare word true: {form}"
3009 );
3010
3011 let record = serde_json::json!({ "config": {"a": 1} });
3013 let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
3014 assert!(
3015 form.contains("{"a":1}") || form.contains(r#"{"a":1}"#),
3016 "object values still render as JSON: {form}"
3017 );
3018
3019 let optional_field = AdminField::new("config", AdminFieldKind::Json).optional();
3022 let record = serde_json::json!({ "config": null });
3023 let form = render_form_widget(&optional_field, Some(&record), true, &[]).into_string();
3024 assert!(
3025 !form.contains("null"),
3026 "an optional NULL json value should render blank, not the literal word null: {form}"
3027 );
3028
3029 let form = render_form_widget(&field, Some(&record), true, &[]).into_string();
3039 assert!(
3040 form.contains(">null<") || form.contains("null"),
3041 "a required NULL json value must render the literal `null`, not blank: {form}"
3042 );
3043 }
3044
3045 #[test]
3046 fn json_create_form_starts_blank_even_when_required() {
3047 let field = AdminField::new("config", AdminFieldKind::Json);
3054 let form = render_form_widget(&field, None, false, &[]).into_string();
3055 assert!(
3056 !form.contains("null"),
3057 "a brand-new required json field on the CREATE form must start blank, \
3058 not prefilled with the literal word null: {form}"
3059 );
3060 }
3061
3062 fn list_result(page: u64, per_page: u64, total: u64) -> crate::traits::ListResult {
3063 crate::traits::ListResult {
3064 total,
3065 per_page,
3066 page,
3067 records: vec![],
3068 }
3069 }
3070
3071 #[test]
3072 fn render_pagination_shows_summary_and_nav() {
3073 let result = list_result(2, 10, 100);
3074 let html = render_pagination(
3075 &result,
3076 "users",
3077 "",
3078 None,
3079 crate::traits::SortDirection::Asc,
3080 "",
3081 "/admin",
3082 )
3083 .into_string();
3084 assert!(html.contains("Showing 11–20 of 100"), "{html}");
3085 assert!(html.contains("autumn-pager"), "{html}");
3086 assert!(html.contains("/admin/users?"), "{html}");
3088 }
3089
3090 #[test]
3091 fn render_pagination_preserves_search_and_sort() {
3092 let result = list_result(5, 10, 200); let html = render_pagination(
3094 &result,
3095 "users",
3096 "foo",
3097 Some("name"),
3098 crate::traits::SortDirection::Asc,
3099 "",
3100 "/admin",
3101 )
3102 .into_string();
3103 for (i, _) in html.match_indices("href=\"") {
3105 let rest = &html[i + 6..];
3106 let end = rest.find('"').unwrap_or(rest.len());
3107 let href = &rest[..end];
3108 assert!(href.contains("q=foo"), "missing q in {href}");
3109 assert!(href.contains("sort=name"), "missing sort in {href}");
3110 }
3111 assert!(html.contains('…'), "{html}");
3113 }
3114
3115 #[test]
3116 fn render_pagination_marks_active_page() {
3117 let result = list_result(3, 10, 100);
3118 let html = render_pagination(
3119 &result,
3120 "users",
3121 "",
3122 None,
3123 crate::traits::SortDirection::Asc,
3124 "",
3125 "/admin",
3126 )
3127 .into_string();
3128 assert!(html.contains(r#"aria-current="page""#), "{html}");
3129 }
3130
3131 #[test]
3132 fn truncate_display_ascii() {
3133 assert_eq!(truncate_display("hello", 10), "hello");
3134 assert_eq!(truncate_display("hello world!", 6), "hello…");
3135 }
3136
3137 #[test]
3138 fn truncate_display_utf8_boundary_safe() {
3139 let s = "日本語日";
3141 assert_eq!(truncate_display(s, 3), "日本…");
3142 assert_eq!(truncate_display(s, 10), s);
3144 }
3145
3146 #[test]
3147 fn url_encode_handles_reserved_chars() {
3148 assert_eq!(url_encode("hello world"), "hello%20world");
3149 assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
3150 assert_eq!(url_encode("safe-._~"), "safe-._~");
3151 }
3152
3153 #[test]
3154 fn url_encode_handles_utf8() {
3155 assert_eq!(url_encode("é"), "%C3%A9");
3157 }
3158
3159 #[test]
3160 fn normalize_datetime_local_accepts_expected_shape() {
3161 assert_eq!(
3162 normalize_datetime_local_input("2026-04-24T12:34"),
3163 "2026-04-24T12:34"
3164 );
3165 }
3166
3167 #[test]
3168 fn normalize_datetime_local_strips_seconds() {
3169 assert_eq!(
3170 normalize_datetime_local_input("2026-04-24T12:34:56"),
3171 "2026-04-24T12:34"
3172 );
3173 }
3174
3175 #[test]
3176 fn normalize_datetime_local_strips_rfc3339_zulu() {
3177 assert_eq!(
3179 normalize_datetime_local_input("2026-04-24T12:34:56Z"),
3180 "2026-04-24T12:34"
3181 );
3182 }
3183
3184 #[test]
3185 fn normalize_datetime_local_preserves_wall_time_across_offsets() {
3186 assert_eq!(
3192 normalize_datetime_local_input("2026-04-24T12:34:56+05:30"),
3193 "2026-04-24T12:34"
3194 );
3195 assert_eq!(
3198 normalize_datetime_local_input("2026-04-24T23:30:00-04:00"),
3199 "2026-04-24T23:30"
3200 );
3201 }
3202
3203 #[test]
3204 fn normalize_datetime_local_empty_stays_empty() {
3205 assert_eq!(normalize_datetime_local_input(""), "");
3206 }
3207
3208 #[test]
3209 fn normalize_datetime_local_leaves_garbage_untouched() {
3210 assert_eq!(normalize_datetime_local_input("not-a-date"), "not-a-date");
3212 }
3213
3214 #[test]
3215 fn normalize_date_accepts_expected_shape() {
3216 assert_eq!(normalize_date_input("2026-04-24"), "2026-04-24");
3217 }
3218
3219 #[test]
3220 fn normalize_date_extracts_from_rfc3339() {
3221 assert_eq!(normalize_date_input("2026-04-24T12:34:56Z"), "2026-04-24");
3222 }
3223
3224 fn dummy_registry() -> AdminRegistry {
3227 AdminRegistry::new()
3228 }
3229
3230 #[test]
3231 fn history_page_pagination_preserves_per_page() {
3232 let r = dummy_registry();
3233 let history = AdminHistoryPage {
3234 entries: vec![crate::traits::AdminHistoryEntry {
3235 id: 1,
3236 actor: "system".to_owned(),
3237 op: "insert".to_owned(),
3238 request_id: None,
3239 changes: vec![],
3240 recorded_at: chrono::Utc::now(),
3241 }],
3242 total: 250,
3243 page: 2,
3244 per_page: 100,
3245 };
3246
3247 let html = model_history_page(
3248 &AdminChrome {
3249 actuator_prefix: "/ops",
3250 ..test_chrome(&r)
3251 },
3252 "posts",
3253 "Post",
3254 "Posts",
3255 42,
3256 &history,
3257 )
3258 .into_string();
3259
3260 assert!(
3261 html.contains("/admin/posts/42/history?page=1&per_page=100"),
3262 "previous history page link must preserve per_page: {html}"
3263 );
3264 assert!(
3265 html.contains("/admin/posts/42/history?page=3&per_page=100"),
3266 "next history page link must preserve per_page: {html}"
3267 );
3268 }
3269
3270 #[test]
3271 fn dashboard_emits_csrf_meta_and_script() {
3272 let r = dummy_registry();
3273 let html = dashboard_page(
3274 &AdminChrome {
3275 actuator_prefix: "/ops",
3276 csrf_token: "tok-123",
3277 ..test_chrome(&r)
3278 },
3279 &[],
3280 )
3281 .into_string();
3282 assert!(
3283 html.contains(r#"<meta name="csrf-token" content="tok-123""#),
3284 "CSRF meta tag missing: {html}"
3285 );
3286 let csrf_src = format!(
3288 r#"src="{}""#,
3289 autumn_web::assets::asset_url("js/autumn-htmx-csrf.js")
3290 );
3291 assert!(
3292 html.contains(&csrf_src),
3293 "HTMX CSRF helper script not loaded: {html}"
3294 );
3295 }
3296
3297 #[test]
3298 fn dashboard_uses_configured_actuator_prefix() {
3299 let r = dummy_registry();
3300 let html = dashboard_page(
3301 &AdminChrome {
3302 actuator_prefix: "/ops",
3303 csrf_token: "tok",
3304 ..test_chrome(&r)
3305 },
3306 &[],
3307 )
3308 .into_string();
3309 assert!(
3310 html.contains(r#"href="/ops/ui""#),
3311 "sidebar link wrong: {html}"
3312 );
3313 assert!(
3314 html.contains(r#"hx-get="/ops/ui/metrics""#),
3315 "metrics polling URL wrong: {html}"
3316 );
3317 assert!(
3318 !html.contains("/actuator/"),
3319 "must not hardcode /actuator when prefix is /ops: {html}"
3320 );
3321 }
3322
3323 #[test]
3324 #[allow(clippy::too_many_lines)]
3325 fn jobs_page_renders_lists_actions_polling_and_csrf() {
3326 use autumn_web::job::{
3327 JobAdminPage, JobAdminRecord, JobAdminSnapshot, JobAdminStatus, JobScheduleSummary,
3328 };
3329
3330 let r = dummy_registry();
3331 let snapshot = JobAdminSnapshot {
3332 enqueued: JobAdminPage::new(
3333 vec![JobAdminRecord {
3334 id: "job-enqueued".to_owned(),
3335 name: "send_email".to_owned(),
3336 queue: "default".to_owned(),
3337 status: JobAdminStatus::Enqueued,
3338 enqueued_at: Some("2026-05-07T10:00:00Z".to_owned()),
3339 scheduled_for: None,
3340 started_at: None,
3341 finished_at: None,
3342 attempt: 1,
3343 max_attempts: 5,
3344 last_error: None,
3345 principal_id: Some("42".to_owned()),
3346 correlation_id: Some("req-123".to_owned()),
3347 blocked_on_concurrency: false,
3348 }],
3349 1,
3350 1,
3351 25,
3352 ),
3353 scheduled: JobAdminPage::new(
3354 vec![JobAdminRecord {
3355 id: "job-scheduled".to_owned(),
3356 name: "reminder".to_owned(),
3357 queue: "default".to_owned(),
3358 status: JobAdminStatus::Scheduled,
3359 enqueued_at: Some("2026-05-07T10:00:00Z".to_owned()),
3360 scheduled_for: Some("2026-05-08T10:00:00Z".to_owned()),
3361 started_at: None,
3362 finished_at: None,
3363 attempt: 1,
3364 max_attempts: 5,
3365 last_error: None,
3366 principal_id: None,
3367 correlation_id: None,
3368 blocked_on_concurrency: false,
3369 }],
3370 1,
3371 1,
3372 25,
3373 ),
3374 running: JobAdminPage::new(
3375 vec![JobAdminRecord {
3376 id: "job-running".to_owned(),
3377 name: "reindex".to_owned(),
3378 queue: "default".to_owned(),
3379 status: JobAdminStatus::Running,
3380 enqueued_at: Some("2026-05-07T10:01:00Z".to_owned()),
3381 scheduled_for: None,
3382 started_at: Some("2026-05-07T10:02:00Z".to_owned()),
3383 finished_at: None,
3384 attempt: 1,
3385 max_attempts: 3,
3386 last_error: None,
3387 principal_id: None,
3388 correlation_id: None,
3389 blocked_on_concurrency: false,
3390 }],
3391 1,
3392 1,
3393 25,
3394 ),
3395 completed: JobAdminPage::new(
3396 vec![JobAdminRecord {
3397 id: "job-complete".to_owned(),
3398 name: "digest".to_owned(),
3399 queue: "default".to_owned(),
3400 status: JobAdminStatus::Completed,
3401 enqueued_at: Some("2026-05-07T09:00:00Z".to_owned()),
3402 scheduled_for: None,
3403 started_at: Some("2026-05-07T09:01:00Z".to_owned()),
3404 finished_at: Some("2026-05-07T09:02:00Z".to_owned()),
3405 attempt: 1,
3406 max_attempts: 3,
3407 last_error: None,
3408 principal_id: None,
3409 correlation_id: None,
3410 blocked_on_concurrency: false,
3411 }],
3412 1,
3413 1,
3414 25,
3415 ),
3416 failed: JobAdminPage::new(
3417 vec![JobAdminRecord {
3418 id: "job-failed".to_owned(),
3419 name: "send_email".to_owned(),
3420 queue: "default".to_owned(),
3421 status: JobAdminStatus::Failed,
3422 enqueued_at: Some("2026-05-07T08:00:00Z".to_owned()),
3423 scheduled_for: None,
3424 started_at: Some("2026-05-07T08:01:00Z".to_owned()),
3425 finished_at: Some("2026-05-07T08:02:00Z".to_owned()),
3426 attempt: 5,
3427 max_attempts: 5,
3428 last_error: Some("smtp refused recipient".repeat(6)),
3429 principal_id: Some("7".to_owned()),
3430 correlation_id: None,
3431 blocked_on_concurrency: false,
3432 }],
3433 1,
3434 1,
3435 25,
3436 ),
3437 schedules: vec![JobScheduleSummary {
3438 name: "send-digest".to_owned(),
3439 schedule: "every 1h".to_owned(),
3440 next_run_at: None,
3441 last_run_status: Some("ok".to_owned()),
3442 }],
3443 bounded_history_limit: 1_000,
3444 };
3445
3446 let html = jobs_page(
3447 &AdminChrome {
3448 csrf_token: "tok-job",
3449 csrf_form_field: "authenticity_token",
3450 ..test_chrome(&r)
3451 },
3452 &snapshot,
3453 )
3454 .into_string();
3455 assert!(html.contains("Jobs"));
3456 assert!(html.contains("Enqueued"));
3457 assert!(html.contains("Running"));
3458 assert!(html.contains("Completed (last 24h)"));
3459 assert!(html.contains("Failed (last 7d)"));
3460 assert!(html.contains("send_email"));
3461 assert!(html.contains("req-123"));
3462 assert!(html.contains(r#"action="/admin/jobs/job-failed/retry""#));
3463 assert!(html.contains(r#"action="/admin/jobs/job-failed/discard""#));
3464 assert!(html.contains(r#"action="/admin/jobs/job-enqueued/cancel""#));
3465 assert!(html.contains("Scheduled"));
3468 assert!(html.contains("due 2026-05-08T10:00:00Z"));
3469 assert!(html.contains(r#"action="/admin/jobs/job-scheduled/cancel""#));
3470 assert!(html.contains(r#"name="authenticity_token" value="tok-job""#));
3471 assert!(!html.contains(r#"name="_csrf" value="tok-job""#));
3472 assert!(html.contains(r#"hx-get="/admin/jobs/counters""#));
3473 assert!(html.contains(r#"hx-trigger="load, every 2s""#));
3474 assert!(html.contains("send-digest"));
3475 }
3476
3477 #[test]
3482 fn job_blocked_marker_uses_the_text_safe_warning_token() {
3483 let start = ADMIN_CSS
3484 .find(".job-blocked")
3485 .expect("missing `.job-blocked` rule in ADMIN_CSS");
3486 let block_end = ADMIN_CSS[start..]
3487 .find('}')
3488 .map_or(ADMIN_CSS.len(), |i| start + i);
3489 let block = &ADMIN_CSS[start..block_end];
3490 assert!(
3491 block.contains("color: var(--warning-text)"),
3492 "`.job-blocked` must use --warning-text: {block}"
3493 );
3494 assert!(
3495 !block.contains("var(--warning)"),
3496 "raw --warning fails WCAG AA as normal text on --surface: {block}"
3497 );
3498 }
3499
3500 #[test]
3503 fn job_row_flags_a_concurrency_parked_job() {
3504 use autumn_web::job::{JobAdminRecord, JobAdminStatus};
3505
3506 let mut record = JobAdminRecord {
3507 id: "job-parked".to_owned(),
3508 name: "recalculate".to_owned(),
3509 queue: "default".to_owned(),
3510 status: JobAdminStatus::Enqueued,
3511 enqueued_at: Some("2026-05-07T10:00:00Z".to_owned()),
3512 scheduled_for: None,
3513 started_at: None,
3514 finished_at: None,
3515 attempt: 1,
3516 max_attempts: 5,
3517 last_error: None,
3518 principal_id: None,
3519 correlation_id: None,
3520 blocked_on_concurrency: true,
3521 };
3522
3523 let parked = job_row(&record, "tok", "authenticity_token", "/admin").into_string();
3524 assert!(
3525 parked.contains("waiting on a concurrency slot"),
3526 "parked row must be annotated: {parked}"
3527 );
3528 assert!(
3530 parked.contains(r#"action="/admin/jobs/job-parked/cancel""#),
3531 "parked row must keep its Cancel action: {parked}"
3532 );
3533
3534 record.blocked_on_concurrency = false;
3535 let ready = job_row(&record, "tok", "authenticity_token", "/admin").into_string();
3536 assert!(
3537 !ready.contains("waiting on a concurrency slot"),
3538 "a ready row must not be annotated: {ready}"
3539 );
3540 }
3541
3542 #[test]
3543 fn jobs_counters_fragment_preserves_polling_after_outer_swap() {
3544 use autumn_web::job::JobAdminSnapshot;
3545
3546 let html = jobs_counters(&JobAdminSnapshot::empty(), "/admin").into_string();
3547 assert!(html.contains(r#"id="jobs-counters""#));
3548 assert!(html.contains(r#"hx-get="/admin/jobs/counters""#));
3549 assert!(html.contains(r#"hx-trigger="load, every 2s""#));
3550 assert!(html.contains(r#"hx-swap="outerHTML""#));
3551 }
3552
3553 #[test]
3554 fn form_page_renders_hidden_csrf_input() {
3555 let r = dummy_registry();
3556 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3557 let html = model_form_page(
3558 &AdminChrome {
3559 csrf_token: "tok-xyz",
3560 csrf_form_field: "authenticity_token",
3561 ..test_chrome(&r)
3562 },
3563 &ModelFormView {
3564 model_slug: "widgets",
3565 model_name: "Widget",
3566 model_name_plural: "Widgets",
3567 fields: &fields,
3568 record: None,
3569 id: None,
3570 raw_fields: &[],
3571 },
3572 )
3573 .into_string();
3574 assert!(
3575 html.contains(r#"<input type="hidden" name="authenticity_token" value="tok-xyz""#),
3576 "custom CSRF hidden field missing: {html}"
3577 );
3578 assert!(!html.contains(r#"name="_csrf" value="tok-xyz""#));
3579 }
3580
3581 #[test]
3582 fn form_page_normalizes_datetime_for_browser_input() {
3583 let r = dummy_registry();
3584 let fields = vec![AdminField::new("created_at", AdminFieldKind::DateTime)];
3585 let record = serde_json::json!({"id": 1, "created_at": "2026-04-24T12:34:56Z"});
3587 let html = model_form_page(
3588 &test_chrome(&r),
3589 &ModelFormView {
3590 model_slug: "widgets",
3591 model_name: "Widget",
3592 model_name_plural: "Widgets",
3593 fields: &fields,
3594 record: Some(&record),
3595 id: Some(1),
3596 raw_fields: &[],
3597 },
3598 )
3599 .into_string();
3600 assert!(
3601 html.contains(r#"value="2026-04-24T12:34""#),
3602 "datetime-local input should carry browser-friendly value: {html}"
3603 );
3604 assert!(
3605 !html.contains(r#"value="2026-04-24T12:34:56Z""#),
3606 "raw RFC3339 must not reach datetime-local input: {html}"
3607 );
3608 }
3609
3610 #[test]
3611 fn form_page_action_uses_path_id_not_payload_id() {
3612 let r = dummy_registry();
3615 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3616 let record = serde_json::json!({"id": 99, "name": "x"});
3617 let html = model_form_page(
3618 &test_chrome(&r),
3619 &ModelFormView {
3620 model_slug: "widgets",
3621 model_name: "Widget",
3622 model_name_plural: "Widgets",
3623 fields: &fields,
3624 record: Some(&record),
3625 id: Some(42),
3626 raw_fields: &[],
3627 },
3628 )
3629 .into_string();
3630 assert!(
3631 html.contains(r#"action="/admin/widgets/42""#),
3632 "form action should use path-based id 42, not payload id 99: {html}"
3633 );
3634 assert!(
3635 !html.contains(r#"action="/admin/widgets/99""#),
3636 "payload-derived id must not appear in form action: {html}"
3637 );
3638 }
3639
3640 #[test]
3641 fn detail_page_edit_delete_links_use_path_id() {
3642 let r = dummy_registry();
3643 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3644 let record = serde_json::json!({"id": 99, "name": "x"});
3645 let html = model_detail_page(
3646 &test_chrome(&r),
3647 &ModelDetailView {
3648 model_slug: "widgets",
3649 model_name: "Widget",
3650 model_name_plural: "Widgets",
3651 fields: &fields,
3652 record: &record,
3653 record_display: "#42",
3654 id: 42,
3655 has_history: false,
3656 },
3657 )
3658 .into_string();
3659 assert!(
3660 html.contains(r#"href="/admin/widgets/42/edit""#),
3661 "Edit link must use path id 42: {html}"
3662 );
3663 assert!(
3664 html.contains(r#"<form action="/admin/widgets/42" method="post""#),
3665 "Delete confirm form must target path id 42: {html}"
3666 );
3667 assert!(
3668 !html.contains("widgets/99"),
3669 "payload id 99 must not route mutations: {html}"
3670 );
3671 assert!(
3674 html.contains(r#"name="_method" value="DELETE""#),
3675 "Delete form must carry a _method override: {html}"
3676 );
3677 assert!(
3678 html.contains(r#"name="_csrf" value="t""#),
3679 "Delete form must carry the CSRF token: {html}"
3680 );
3681 }
3682
3683 #[test]
3684 fn detail_page_delete_uses_confirm_dialog_not_hx_confirm() {
3685 let r = dummy_registry();
3689 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3690 let record = serde_json::json!({"id": 42, "name": "x"});
3691 let html = model_detail_page(
3692 &test_chrome(&r),
3693 &ModelDetailView {
3694 model_slug: "widgets",
3695 model_name: "Widget",
3696 model_name_plural: "Widgets",
3697 fields: &fields,
3698 record: &record,
3699 record_display: "#42",
3700 id: 42,
3701 has_history: false,
3702 },
3703 )
3704 .into_string();
3705 assert!(!html.contains("hx-confirm"), "{html}");
3706 assert!(!html.contains("hx-delete"), "{html}");
3707 assert!(html.contains("<dialog"), "{html}");
3708 assert!(html.contains(r#"aria-modal="true""#), "{html}");
3709 assert!(
3710 html.contains("Widget"),
3711 "confirm dialog title should mention the model name: {html}"
3712 );
3713 assert!(html.contains(r#"command="show-modal""#), "{html}");
3716 assert!(html.contains("data-modal-open"), "{html}");
3717 }
3718
3719 #[test]
3720 fn detail_page_delete_button_honors_custom_csrf_form_field() {
3721 let r = dummy_registry();
3726 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3727 let record = serde_json::json!({"id": 42, "name": "x"});
3728 let html = model_detail_page(
3729 &AdminChrome {
3730 csrf_form_field: "authenticity_token",
3731 ..test_chrome(&r)
3732 },
3733 &ModelDetailView {
3734 model_slug: "widgets",
3735 model_name: "Widget",
3736 model_name_plural: "Widgets",
3737 fields: &fields,
3738 record: &record,
3739 record_display: "#42",
3740 id: 42,
3741 has_history: false,
3742 },
3743 )
3744 .into_string();
3745 assert!(
3746 html.contains(r#"name="authenticity_token" value="t""#),
3747 "Delete form must use the configured CSRF field name: {html}"
3748 );
3749 assert!(
3750 !html.contains(r#"name="_csrf""#),
3751 "Delete form must not fall back to the default CSRF field name: {html}"
3752 );
3753 }
3754
3755 #[test]
3756 fn detail_view_escapes_malicious_json() {
3757 let r = dummy_registry();
3758 let fields = vec![AdminField::new("meta", AdminFieldKind::Json)];
3759 let record = serde_json::json!({
3762 "id": 1,
3763 "meta": {"xss": "<script>alert(1)</script>"},
3764 });
3765 let html = model_detail_page(
3766 &test_chrome(&r),
3767 &ModelDetailView {
3768 model_slug: "widgets",
3769 model_name: "Widget",
3770 model_name_plural: "Widgets",
3771 fields: &fields,
3772 record: &record,
3773 record_display: "#1",
3774 id: 1,
3775 has_history: false,
3776 },
3777 )
3778 .into_string();
3779 assert!(
3780 !html.contains("<script>alert(1)</script>"),
3781 "raw <script> must be escaped: {html}"
3782 );
3783 assert!(
3784 html.contains("<script>alert(1)</script>"),
3785 "escaped form expected: {html}"
3786 );
3787 }
3788
3789 #[test]
3790 fn layout_loads_external_admin_js_not_inline() {
3791 let r = dummy_registry();
3796 let html = dashboard_page(&test_chrome(&r), &[]).into_string();
3797 let asset = crate::routes::ASSETS
3798 .get("admin.js")
3799 .expect("admin.js is in the bundle");
3800 let expected = format!(r#"src="{}""#, asset.url());
3801 assert!(
3802 html.contains(&expected),
3803 "admin.js must be referenced as an external script at {expected}: {html}"
3804 );
3805 assert!(
3808 asset
3809 .url()
3810 .starts_with("/static/_plugins/autumn-admin/admin.")
3811 && asset.url().rsplit('.').next() == Some("js")
3812 && asset.url() != asset.plain_url(),
3813 "admin.js URL should be fingerprinted (admin.<hash>.js): {}",
3814 asset.url()
3815 );
3816 assert!(
3817 html.contains(&format!(r#"integrity="{}""#, asset.integrity())),
3818 "admin.js tag should carry its SRI hash: {html}"
3819 );
3820 assert!(
3821 !html.contains(r#"src="/static/_plugins/autumn-admin/admin.js""#),
3822 "unfingerprinted URL would invalidate immutable caching: {html}"
3823 );
3824 assert!(
3827 !html.contains("onclick=\""),
3828 "no inline event handlers allowed under default CSP: {html}"
3829 );
3830 }
3831
3832 #[test]
3833 fn list_page_hides_hidden_fields_even_if_list_display_true() {
3834 use crate::traits::ListResult;
3835 let r = dummy_registry();
3836 let fields = vec![
3839 AdminField::new("name", AdminFieldKind::Text),
3840 AdminField::new("internal_token", AdminFieldKind::Hidden),
3841 ];
3842 let result = ListResult {
3843 records: vec![serde_json::json!({
3844 "id": 1,
3845 "name": "alice",
3846 "internal_token": "INT-9999",
3847 })],
3848 total: 1,
3849 page: 1,
3850 per_page: 25,
3851 };
3852 let html = model_list_page(
3853 &test_chrome(&r),
3854 &ModelListView {
3855 model_slug: "users",
3856 model_name_plural: "Users",
3857 fields: &fields,
3858 actions: &[],
3859 result: &result,
3860 search_query: "",
3861 sort_by: None,
3862 sort_dir: SortDirection::Asc,
3863 filters: &[],
3864 supports_csv_export: false,
3865 supports_csv_import: false,
3866 },
3867 )
3868 .into_string();
3869 assert!(
3870 !html.contains("INT-9999"),
3871 "hidden field value must not surface in list view: {html}"
3872 );
3873 assert!(
3874 !html.contains("internal_token") && !html.contains("Internal Token"),
3875 "hidden field column header must not appear: {html}"
3876 );
3877 }
3878
3879 #[test]
3880 fn list_page_hides_password_fields_even_if_list_display_true() {
3881 use crate::traits::ListResult;
3882 let r = dummy_registry();
3883 let fields = vec![
3886 AdminField::new("name", AdminFieldKind::Text),
3887 AdminField::new("password_hash", AdminFieldKind::Password),
3888 ];
3889 let result = ListResult {
3890 records: vec![serde_json::json!({
3891 "id": 1,
3892 "name": "alice",
3893 "password_hash": "$argon2id$leaked",
3894 })],
3895 total: 1,
3896 page: 1,
3897 per_page: 25,
3898 };
3899 let html = model_list_page(
3900 &test_chrome(&r),
3901 &ModelListView {
3902 model_slug: "users",
3903 model_name_plural: "Users",
3904 fields: &fields,
3905 actions: &[],
3906 result: &result,
3907 search_query: "",
3908 sort_by: None,
3909 sort_dir: SortDirection::Asc,
3910 filters: &[],
3911 supports_csv_export: false,
3912 supports_csv_import: false,
3913 },
3914 )
3915 .into_string();
3916 assert!(
3917 !html.contains("$argon2id$leaked"),
3918 "raw password hash must not appear in list view: {html}"
3919 );
3920 assert!(
3921 !html.contains("password_hash"),
3922 "password column must not have a header in list view: {html}"
3923 );
3924 }
3925
3926 #[test]
3927 fn list_page_handles_records_without_numeric_id() {
3928 use crate::traits::ListResult;
3932 let r = dummy_registry();
3933 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
3934 let result = ListResult {
3935 records: vec![
3936 serde_json::json!({"id": 7, "name": "with id"}),
3937 serde_json::json!({"name": "no id"}),
3938 ],
3939 total: 2,
3940 page: 1,
3941 per_page: 25,
3942 };
3943 let html = model_list_page(
3944 &test_chrome(&r),
3945 &ModelListView {
3946 model_slug: "widgets",
3947 model_name_plural: "Widgets",
3948 fields: &fields,
3949 actions: &[],
3950 result: &result,
3951 search_query: "",
3952 sort_by: None,
3953 sort_dir: SortDirection::Asc,
3954 filters: &[],
3955 supports_csv_export: false,
3956 supports_csv_import: false,
3957 },
3958 )
3959 .into_string();
3960 assert!(
3962 html.contains(r#"href="/admin/widgets/7""#),
3963 "row with id should have working View link: {html}"
3964 );
3965 assert!(
3967 html.contains(r#"<span style="color: var(--text-muted); font-size: 0.75rem;">no id"#)
3968 || html.contains("no id</span>"),
3969 "row without id should show 'no id' placeholder: {html}"
3970 );
3971 assert!(
3972 !html.contains("/admin/widgets/0"),
3973 "must not generate /0 links for rows missing id: {html}"
3974 );
3975 }
3976
3977 #[test]
3978 fn list_page_carries_filters_into_sort_and_pagination_links() {
3979 use crate::traits::ListResult;
3984 let r = dummy_registry();
3985 let mut name = AdminField::new("name", AdminFieldKind::Text);
3986 name.sortable = true;
3987 let fields = vec![name];
3988 let result = ListResult {
3990 records: vec![serde_json::json!({"id": 1, "name": "alice"})],
3991 total: 60,
3992 page: 1,
3993 per_page: 25,
3994 };
3995 let active_filters = vec![
3996 ("status".to_owned(), "active".to_owned()),
3997 ("tier".to_owned(), "premium".to_owned()),
3998 ];
3999 let html = model_list_page(
4000 &test_chrome(&r),
4001 &ModelListView {
4002 model_slug: "users",
4003 model_name_plural: "Users",
4004 fields: &fields,
4005 actions: &[],
4006 result: &result,
4007 search_query: "",
4008 sort_by: None,
4009 sort_dir: SortDirection::Asc,
4010 filters: &active_filters,
4011 supports_csv_export: false,
4012 supports_csv_import: false,
4013 },
4014 )
4015 .into_string();
4016 assert!(
4018 html.contains("filter.status=active"),
4019 "sort link must preserve filter.status: {html}"
4020 );
4021 assert!(
4022 html.contains("filter.tier=premium"),
4023 "sort link must preserve filter.tier: {html}"
4024 );
4025 assert!(
4027 html.contains("page=2") && html.contains("filter.status=active"),
4028 "pagination link must preserve filter.status: {html}"
4029 );
4030 }
4031
4032 #[test]
4033 fn search_form_carries_filters_as_hidden_inputs() {
4034 use crate::traits::ListResult;
4038 let r = dummy_registry();
4039 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4040 let result = ListResult {
4041 records: vec![],
4042 total: 0,
4043 page: 1,
4044 per_page: 25,
4045 };
4046 let active_filters = vec![
4047 ("status".to_owned(), "active".to_owned()),
4048 ("tier".to_owned(), "premium".to_owned()),
4049 ];
4050 let html = model_list_page(
4051 &test_chrome(&r),
4052 &ModelListView {
4053 model_slug: "users",
4054 model_name_plural: "Users",
4055 fields: &fields,
4056 actions: &[],
4057 result: &result,
4058 search_query: "",
4059 sort_by: None,
4060 sort_dir: SortDirection::Asc,
4061 filters: &active_filters,
4062 supports_csv_export: false,
4063 supports_csv_import: false,
4064 },
4065 )
4066 .into_string();
4067 assert!(
4068 html.contains(r#"<input type="hidden" name="filter.status" value="active""#),
4069 "search form should preserve filter.status: {html}"
4070 );
4071 assert!(
4072 html.contains(r#"<input type="hidden" name="filter.tier" value="premium""#),
4073 "search form should preserve filter.tier: {html}"
4074 );
4075 assert!(
4081 html.contains(r#"hx-include="closest form""#),
4082 "search input must hx-include the form so live-search carries filters: {html}"
4083 );
4084 }
4085
4086 #[test]
4087 fn list_page_url_encodes_filter_values() {
4088 use crate::traits::ListResult;
4091 let r = dummy_registry();
4092 let mut name = AdminField::new("name", AdminFieldKind::Text);
4093 name.sortable = true;
4094 let fields = vec![name];
4095 let result = ListResult {
4096 records: vec![],
4097 total: 0,
4098 page: 1,
4099 per_page: 25,
4100 };
4101 let active_filters = vec![("q".to_owned(), "a&b=c".to_owned())];
4102 let html = model_list_page(
4103 &test_chrome(&r),
4104 &ModelListView {
4105 model_slug: "users",
4106 model_name_plural: "Users",
4107 fields: &fields,
4108 actions: &[],
4109 result: &result,
4110 search_query: "",
4111 sort_by: None,
4112 sort_dir: SortDirection::Asc,
4113 filters: &active_filters,
4114 supports_csv_export: false,
4115 supports_csv_import: false,
4116 },
4117 )
4118 .into_string();
4119 assert!(
4120 html.contains("filter.q=a%26b%3Dc"),
4121 "filter values must be percent-encoded in generated links: {html}"
4122 );
4123 }
4124
4125 #[test]
4126 fn list_page_renders_bulk_action_form() {
4127 use crate::traits::{ActionStyle, ListResult};
4128 let r = dummy_registry();
4129 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4130 let actions = vec![
4131 AdminAction {
4132 name: "delete",
4133 label: "Delete selected".to_owned(),
4134 style: ActionStyle::Danger,
4135 confirm: true,
4136 },
4137 AdminAction {
4138 name: "archive",
4139 label: "Archive".to_owned(),
4140 style: ActionStyle::Default,
4141 confirm: false,
4142 },
4143 ];
4144 let result = ListResult {
4145 records: vec![serde_json::json!({"id": 1, "name": "x"})],
4146 total: 1,
4147 page: 1,
4148 per_page: 25,
4149 };
4150 let html = model_list_page(
4151 &AdminChrome {
4152 csrf_token: "tok",
4153 csrf_form_field: "admin_csrf",
4154 ..test_chrome(&r)
4155 },
4156 &ModelListView {
4157 model_slug: "widgets",
4158 model_name_plural: "Widgets",
4159 fields: &fields,
4160 actions: &actions,
4161 result: &result,
4162 search_query: "",
4163 sort_by: None,
4164 sort_dir: SortDirection::Asc,
4165 filters: &[],
4166 supports_csv_export: false,
4167 supports_csv_import: false,
4168 },
4169 )
4170 .into_string();
4171 assert!(
4173 html.contains(r#"action="/admin/widgets/actions""#),
4174 "list view must wrap table in a form posting to /actions: {html}"
4175 );
4176 assert!(
4177 html.contains(r#"name="admin_csrf" value="tok""#),
4178 "configured CSRF token field must be in the bulk-action form: {html}"
4179 );
4180 assert!(!html.contains(r#"name="_csrf" value="tok""#));
4181 assert!(html.contains(r#"value="delete""#));
4184 assert!(html.contains(r#"value="archive""#));
4185 assert!(
4186 html.contains(r#"data-confirm="1""#),
4187 "destructive action should set data-confirm: {html}"
4188 );
4189 assert!(
4194 html.contains(r#"<dialog id="admin-bulk-confirm""#),
4195 "list view with a confirm-requiring action must render the bulk confirm dialog: {html}"
4196 );
4197 assert!(html.contains("data-bulk-confirm-detail"), "{html}");
4198 assert!(
4204 html.match_indices("data-bulk-confirm")
4205 .any(|(i, m)| html.as_bytes().get(i + m.len()) != Some(&b'-')),
4206 "confirm button must carry a standalone data-bulk-confirm attribute: {html}"
4207 );
4208 assert!(!html.contains("window.confirm"), "{html}");
4209 }
4210
4211 #[test]
4212 fn list_page_skips_bulk_confirm_dialog_when_no_action_requires_confirm() {
4213 use crate::traits::{ActionStyle, ListResult};
4214 let r = dummy_registry();
4215 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4216 let actions = vec![AdminAction {
4217 name: "archive",
4218 label: "Archive".to_owned(),
4219 style: ActionStyle::Default,
4220 confirm: false,
4221 }];
4222 let result = ListResult {
4223 records: vec![serde_json::json!({"id": 1, "name": "x"})],
4224 total: 1,
4225 page: 1,
4226 per_page: 25,
4227 };
4228 let html = model_list_page(
4229 &AdminChrome {
4230 csrf_token: "tok",
4231 csrf_form_field: "admin_csrf",
4232 ..test_chrome(&r)
4233 },
4234 &ModelListView {
4235 model_slug: "widgets",
4236 model_name_plural: "Widgets",
4237 fields: &fields,
4238 actions: &actions,
4239 result: &result,
4240 search_query: "",
4241 sort_by: None,
4242 sort_dir: SortDirection::Asc,
4243 filters: &[],
4244 supports_csv_export: false,
4245 supports_csv_import: false,
4246 },
4247 )
4248 .into_string();
4249 assert!(
4250 !html.contains("admin-bulk-confirm"),
4251 "no action requires confirmation, so no dialog should render: {html}"
4252 );
4253 }
4254
4255 #[test]
4256 fn list_page_skips_action_bar_when_no_actions_declared() {
4257 use crate::traits::ListResult;
4258 let r = dummy_registry();
4259 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4260 let result = ListResult {
4261 records: vec![],
4262 total: 0,
4263 page: 1,
4264 per_page: 25,
4265 };
4266 let html = model_list_page(
4267 &test_chrome(&r),
4268 &ModelListView {
4269model_slug: "widgets",
4270model_name_plural: "Widgets",
4271fields: &fields,
4272actions: &[],
4273result: &result,
4275search_query: "",
4276sort_by: None,
4277sort_dir: SortDirection::Asc,
4278filters: &[],
4279supports_csv_export: false,
4280supports_csv_import: false,
4281},
4282 )
4283 .into_string();
4284 assert!(
4285 !html.contains("class=\"action-bar\""),
4286 "no action-bar should render when actions is empty: {html}"
4287 );
4288 }
4289
4290 #[test]
4291 fn list_page_omits_sort_link_for_unsortable_fields() {
4292 use crate::traits::ListResult;
4293 let r = dummy_registry();
4294 let mut computed = AdminField::new("computed", AdminFieldKind::Text).label("Computed");
4296 computed.sortable = false;
4297 let fields = vec![AdminField::new("name", AdminFieldKind::Text), computed];
4298 let result = ListResult {
4299 records: vec![],
4300 total: 0,
4301 page: 1,
4302 per_page: 25,
4303 };
4304 let html = model_list_page(
4305 &AdminChrome {
4306 csrf_token: "tok",
4307 ..test_chrome(&r)
4308 },
4309 &ModelListView {
4310 model_slug: "widgets",
4311 model_name_plural: "Widgets",
4312 fields: &fields,
4313 actions: &[],
4314 result: &result,
4315 search_query: "",
4316 sort_by: None,
4317 sort_dir: SortDirection::Asc,
4318 filters: &[],
4319 supports_csv_export: false,
4320 supports_csv_import: false,
4321 },
4322 )
4323 .into_string();
4324 assert!(
4326 html.contains(r#"href="/admin/widgets?sort=name"#),
4327 "sortable field should have a sort link: {html}"
4328 );
4329 assert!(
4331 !html.contains("sort=computed"),
4332 "non-sortable field must not emit a sort link: {html}"
4333 );
4334 assert!(
4336 html.contains("Computed"),
4337 "label should still render: {html}"
4338 );
4339 }
4340
4341 #[test]
4342 fn list_page_shows_csv_download_link_when_export_enabled() {
4343 use crate::traits::ListResult;
4344 let r = dummy_registry();
4345 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4346 let result = ListResult {
4347 records: vec![],
4348 total: 0,
4349 page: 1,
4350 per_page: 25,
4351 };
4352 let html = model_list_page(
4354 &AdminChrome {
4355impersonation: None,
4357..test_chrome(&r)
4358},
4359 &ModelListView {
4360model_slug: "widgets",
4361model_name_plural: "Widgets",
4362fields: &fields,
4363actions: &[],
4364result: &result,
4365search_query: "",
4366sort_by: None,
4367sort_dir: SortDirection::Asc,
4368filters: &[],
4369supports_csv_export: true,
4371supports_csv_import: false,
4373},
4374 )
4375 .into_string();
4376 assert!(
4377 html.contains(r#"href="/admin/widgets/export.csv""#),
4378 "Download CSV link must appear when supports_csv_export=true: {html}"
4379 );
4380 assert!(
4381 !html.contains("/import"),
4382 "Import CSV link must not appear when supports_csv_import=false: {html}"
4383 );
4384 }
4385
4386 #[test]
4387 fn list_page_shows_import_link_when_import_enabled() {
4388 use crate::traits::ListResult;
4389 let r = dummy_registry();
4390 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4391 let result = ListResult {
4392 records: vec![],
4393 total: 0,
4394 page: 1,
4395 per_page: 25,
4396 };
4397 let html = model_list_page(
4398 &AdminChrome {
4399impersonation: None,
4401..test_chrome(&r)
4402},
4403 &ModelListView {
4404model_slug: "widgets",
4405model_name_plural: "Widgets",
4406fields: &fields,
4407actions: &[],
4408result: &result,
4409search_query: "",
4410sort_by: None,
4411sort_dir: SortDirection::Asc,
4412filters: &[],
4413supports_csv_export: false,
4415supports_csv_import: true,
4417},
4418 )
4419 .into_string();
4420 assert!(
4421 html.contains(r#"href="/admin/widgets/import""#),
4422 "Import CSV link must appear when supports_csv_import=true: {html}"
4423 );
4424 assert!(
4425 !html.contains("export.csv"),
4426 "Download CSV link must not appear when supports_csv_export=false: {html}"
4427 );
4428 }
4429
4430 #[test]
4431 fn list_page_hides_csv_buttons_when_both_disabled() {
4432 use crate::traits::ListResult;
4433 let r = dummy_registry();
4434 let fields = vec![AdminField::new("name", AdminFieldKind::Text)];
4435 let result = ListResult {
4436 records: vec![],
4437 total: 0,
4438 page: 1,
4439 per_page: 25,
4440 };
4441 let html = model_list_page(
4442 &test_chrome(&r),
4443 &ModelListView {
4444 model_slug: "widgets",
4445 model_name_plural: "Widgets",
4446 fields: &fields,
4447 actions: &[],
4448 result: &result,
4449 search_query: "",
4450 sort_by: None,
4451 sort_dir: SortDirection::Asc,
4452 filters: &[],
4453 supports_csv_export: false,
4454 supports_csv_import: false,
4455 },
4456 )
4457 .into_string();
4458 assert!(
4459 !html.contains("export.csv"),
4460 "no export link when disabled: {html}"
4461 );
4462 assert!(
4463 !html.contains("/import"),
4464 "no import link when disabled: {html}"
4465 );
4466 }
4467
4468 #[test]
4469 fn admin_js_does_not_contain_inline_event_handlers() {
4470 let js = include_str!("admin.js");
4472 assert!(
4473 js.contains("select-all"),
4474 "admin.js should wire the select-all checkbox"
4475 );
4476 assert!(
4477 js.contains("removeAttribute(\"name\")"),
4478 "admin.js should strip blank password input names"
4479 );
4480 }
4481
4482 #[test]
4483 fn admin_js_uses_confirm_dialog_as_primary_path() {
4484 let js = include_str!("admin.js");
4488 assert!(js.contains("data-bulk-confirm"), "{js}");
4489 assert!(js.contains("showModal"), "{js}");
4490 }
4491
4492 #[test]
4493 fn admin_js_window_confirm_only_reached_as_showmodal_fallback() {
4494 let js = include_str!("admin.js");
4501 let guard_idx = js
4502 .find("!dialog.showModal")
4503 .unwrap_or_else(|| panic!("must feature-detect <dialog>.showModal support: {js}"));
4504 let confirm_idx = js
4508 .find("window.confirm(message)")
4509 .unwrap_or_else(|| panic!("must keep a window.confirm() fallback: {js}"));
4510 assert!(
4511 confirm_idx > guard_idx,
4512 "window.confirm() must only be reached after the showModal support guard: {js}"
4513 );
4514 }
4515
4516 #[test]
4517 fn admin_js_defers_showmodal_fallback_resubmit() {
4518 let js = include_str!("admin.js");
4527 let confirm_idx = js
4528 .find("window.confirm(message)")
4529 .unwrap_or_else(|| panic!("must keep a window.confirm() fallback: {js}"));
4530 let defer_idx = js
4531 .find("setTimeout")
4532 .unwrap_or_else(|| panic!("fallback resubmit must be deferred: {js}"));
4533 assert!(
4534 defer_idx > confirm_idx,
4535 "the deferred resubmit must be inside the window.confirm() fallback branch: {js}"
4536 );
4537 }
4538
4539 #[test]
4540 fn admin_js_requestsubmit_has_form_submit_fallback() {
4541 let js = include_str!("admin.js");
4549 assert!(
4550 js.contains(r#"typeof form.requestSubmit === "function""#),
4551 "must feature-detect requestSubmit before calling it: {js}"
4552 );
4553 assert!(
4554 js.contains("form.submit();"),
4555 "must fall back to form.submit() when requestSubmit is unavailable: {js}"
4556 );
4557 assert_eq!(
4563 js.matches("form.requestSubmit();").count(),
4564 1,
4565 "form.requestSubmit() should only be called from inside the \
4566 feature-detected submitForm() helper, not unguarded elsewhere: {js}"
4567 );
4568 assert_eq!(
4569 js.matches("submitForm(form);").count(),
4570 2,
4571 "both bulk-action resubmit call sites must route through submitForm(): {js}"
4572 );
4573 }
4574
4575 #[test]
4576 fn admin_js_clears_bulk_confirmed_before_fallback_submit() {
4577 let js = include_str!("admin.js");
4587 let fallback_idx = js
4588 .find("form.submit();")
4589 .unwrap_or_else(|| panic!("must keep a form.submit() fallback: {js}"));
4590 let clear_idx = js
4595 .find("delete form.dataset.bulkConfirmed;")
4596 .unwrap_or_else(|| {
4597 panic!("must clear bulkConfirmed before the form.submit() fallback: {js}")
4598 });
4599 assert!(
4600 clear_idx < fallback_idx,
4601 "bulkConfirmed must be cleared BEFORE the form.submit() fallback call \
4602 (form.submit() bypasses the submit event, so clearing it after — or \
4603 relying on the submit-event listener to clear it — never happens): {js}"
4604 );
4605 let submit_form_idx = js
4609 .find("function submitForm(form)")
4610 .unwrap_or_else(|| panic!("submitForm() helper must exist: {js}"));
4611 assert!(
4612 submit_form_idx < clear_idx && clear_idx < fallback_idx,
4613 "the bulkConfirmed clear must be inside submitForm(), immediately \
4614 guarding its form.submit() fallback: {js}"
4615 );
4616 }
4617
4618 #[test]
4619 fn pagination_range_start_underflow_protection() {
4620 let result = crate::traits::ListResult {
4622 total: 10,
4623 per_page: 5,
4624 page: 0,
4625 records: vec![],
4626 };
4627 let _ = render_pagination(
4630 &result,
4631 "y",
4632 "x",
4633 None,
4634 crate::traits::SortDirection::Asc,
4635 "",
4636 "",
4637 );
4638 }
4639
4640 #[test]
4643 fn config_page_empty_shows_no_keys_registered_message() {
4644 let r = dummy_registry();
4645 let html = config_page(
4646 &AdminChrome {
4647 csrf_token: "tok",
4648 ..test_chrome(&r)
4649 },
4650 &[],
4651 )
4652 .into_string();
4653 assert!(
4654 html.contains("No config keys have been registered"),
4655 "empty state message missing: {html}"
4656 );
4657 assert!(
4658 html.contains("Runtime Config"),
4659 "page title missing: {html}"
4660 );
4661 }
4662
4663 #[test]
4664 fn config_page_renders_key_name_type_and_value() {
4665 use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
4666
4667 let r = dummy_registry();
4668 let entries = vec![ConfigEntry {
4669 name: "max_upload_mb".to_owned(),
4670 value_type: ConfigValueType::Int,
4671 current: ConfigValue::Int(50),
4672 default: ConfigValue::Int(50),
4673 is_overridden: false,
4674 description: Some("Max upload in MB".to_owned()),
4675 }];
4676 let html = config_page(
4677 &AdminChrome {
4678 csrf_token: "tok",
4679 ..test_chrome(&r)
4680 },
4681 &entries,
4682 )
4683 .into_string();
4684 assert!(html.contains("max_upload_mb"), "key name missing: {html}");
4685 assert!(
4686 html.contains("Max upload in MB"),
4687 "description missing: {html}"
4688 );
4689 assert!(
4690 html.contains(r#"action="/admin/config/max_upload_mb/set""#),
4691 "set form action missing: {html}"
4692 );
4693 assert!(
4694 html.contains(r#"href="/admin/config/max_upload_mb/history""#),
4695 "history link missing: {html}"
4696 );
4697 }
4698
4699 #[test]
4700 fn config_page_overridden_key_shows_unset_form() {
4701 use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
4702
4703 let r = dummy_registry();
4704 let entries = vec![ConfigEntry {
4705 name: "rate_limit".to_owned(),
4706 value_type: ConfigValueType::Int,
4707 current: ConfigValue::Int(200),
4708 default: ConfigValue::Int(100),
4709 is_overridden: true,
4710 description: None,
4711 }];
4712 let html = config_page(
4713 &AdminChrome {
4714 csrf_token: "tok",
4715 ..test_chrome(&r)
4716 },
4717 &entries,
4718 )
4719 .into_string();
4720 assert!(
4721 html.contains(r#"action="/admin/config/rate_limit/unset""#),
4722 "unset form should appear for overridden key: {html}"
4723 );
4724 }
4725
4726 #[test]
4727 fn config_page_shows_overridden_status() {
4728 use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
4729
4730 let r = dummy_registry();
4731 let entries = vec![ConfigEntry {
4732 name: "rate_limit".to_owned(),
4733 value_type: ConfigValueType::Int,
4734 current: ConfigValue::Int(200),
4735 default: ConfigValue::Int(100),
4736 is_overridden: true,
4737 description: None,
4738 }];
4739 let html = config_page(
4740 &AdminChrome {
4741 csrf_token: "tok",
4742 ..test_chrome(&r)
4743 },
4744 &entries,
4745 )
4746 .into_string();
4747 assert!(
4748 html.to_lowercase().contains("overridden"),
4749 "overridden status missing: {html}"
4750 );
4751 }
4752
4753 #[test]
4754 fn config_page_shows_default_status_for_unoverridden_key() {
4755 use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
4756
4757 let r = dummy_registry();
4758 let entries = vec![ConfigEntry {
4759 name: "feature_flag".to_owned(),
4760 value_type: ConfigValueType::Bool,
4761 current: ConfigValue::Bool(false),
4762 default: ConfigValue::Bool(false),
4763 is_overridden: false,
4764 description: None,
4765 }];
4766 let html = config_page(
4767 &AdminChrome {
4768 csrf_token: "tok",
4769 ..test_chrome(&r)
4770 },
4771 &entries,
4772 )
4773 .into_string();
4774 assert!(
4775 html.to_lowercase().contains("default"),
4776 "default status missing: {html}"
4777 );
4778 }
4779
4780 #[test]
4781 fn config_page_embeds_csrf_token_in_forms() {
4782 use autumn_web::runtime_config::{ConfigEntry, ConfigValue, ConfigValueType};
4783
4784 let r = dummy_registry();
4785 let entries = vec![ConfigEntry {
4786 name: "timeout_secs".to_owned(),
4787 value_type: ConfigValueType::Int,
4788 current: ConfigValue::Int(30),
4789 default: ConfigValue::Int(30),
4790 is_overridden: false,
4791 description: None,
4792 }];
4793 let html = config_page(
4794 &AdminChrome {
4795 csrf_token: "csrf-tok-789",
4796 csrf_form_field: "authenticity_token",
4797 ..test_chrome(&r)
4798 },
4799 &entries,
4800 )
4801 .into_string();
4802 assert!(
4803 html.contains(r#"name="authenticity_token" value="csrf-tok-789""#),
4804 "CSRF token not embedded in config forms: {html}"
4805 );
4806 }
4807
4808 #[test]
4809 fn config_history_page_shows_empty_state() {
4810 let r = dummy_registry();
4811 let html = config_history_page(
4812 &AdminChrome {
4813 csrf_token: "tok",
4814 ..test_chrome(&r)
4815 },
4816 "rate_limit",
4817 &[],
4818 )
4819 .into_string();
4820 assert!(
4821 html.contains("No changes recorded"),
4822 "empty history message missing: {html}"
4823 );
4824 assert!(html.contains("rate_limit"), "key name missing: {html}");
4825 }
4826
4827 #[test]
4828 fn config_history_page_renders_change_records() {
4829 use autumn_web::runtime_config::{ConfigChangeRecord, ConfigValue};
4830
4831 let r = dummy_registry();
4832 let history = vec![ConfigChangeRecord {
4833 key: "rate_limit".to_owned(),
4834 old_value: Some(ConfigValue::Int(100)),
4835 new_value: Some(ConfigValue::Int(200)),
4836 actor: Some("ops@example.com".to_owned()),
4837 timestamp_secs: 1_700_000_000,
4838 }];
4839 let html = config_history_page(
4840 &AdminChrome {
4841 csrf_token: "tok",
4842 ..test_chrome(&r)
4843 },
4844 "rate_limit",
4845 &history,
4846 )
4847 .into_string();
4848 assert!(html.contains("rate_limit"), "key name missing: {html}");
4849 assert!(html.contains("ops@example.com"), "actor missing: {html}");
4850 assert!(html.contains("100"), "old value missing: {html}");
4851 assert!(html.contains("200"), "new value missing: {html}");
4852 }
4853
4854 #[test]
4855 fn config_history_page_handles_unset_record() {
4856 use autumn_web::runtime_config::{ConfigChangeRecord, ConfigValue};
4857
4858 let r = dummy_registry();
4859 let history = vec![ConfigChangeRecord {
4860 key: "flag".to_owned(),
4861 old_value: Some(ConfigValue::Bool(true)),
4862 new_value: None,
4863 actor: None,
4864 timestamp_secs: 0,
4865 }];
4866 let html = config_history_page(
4867 &AdminChrome {
4868 csrf_token: "tok",
4869 ..test_chrome(&r)
4870 },
4871 "flag",
4872 &history,
4873 )
4874 .into_string();
4875 assert!(html.contains("flag"), "key name missing: {html}");
4876 assert!(html.contains("—"), "null actor placeholder missing: {html}");
4878 }
4879
4880 #[test]
4881 fn format_timestamp_formats_unix_epoch() {
4882 let s = format_timestamp(0);
4883 assert!(s.contains("1970"), "epoch should format as 1970: {s}");
4884 }
4885
4886 #[test]
4887 fn format_timestamp_formats_known_instant() {
4888 let s = format_timestamp(1_700_000_000);
4890 assert!(s.contains("2023"), "expected 2023 in formatted output: {s}");
4891 }
4892
4893 fn banner_state(target: &str, operator: &str) -> ImpersonationBanner {
4896 ImpersonationBanner {
4897 effective_user_id: target.to_owned(),
4898 impersonator_id: operator.to_owned(),
4899 admin_prefix: "/admin".to_owned(),
4900 csrf_token: String::new(),
4901 csrf_form_field: String::new(),
4902 return_to: String::new(),
4903 }
4904 }
4905
4906 #[test]
4907 fn banner_names_both_parties_and_offers_a_revert() {
4908 let html = impersonation_banner(&banner_state("user-9", "admin-1")).into_string();
4909 assert!(html.contains("Viewing as"), "{html}");
4910 assert!(html.contains("user-9"), "{html}");
4911 assert!(html.contains("admin-1"), "{html}");
4912 assert!(html.contains("Stop impersonating"), "{html}");
4913 assert!(
4914 html.contains(r#"action="/admin/impersonate/stop""#),
4915 "{html}"
4916 );
4917 assert!(html.contains(r#"method="post""#), "{html}");
4918 }
4919
4920 #[test]
4921 fn banner_omits_the_csrf_field_when_no_token_is_available() {
4922 let html = impersonation_banner(&banner_state("user-9", "admin-1")).into_string();
4925 assert!(!html.contains(r#"type="hidden""#), "{html}");
4926 }
4927
4928 #[test]
4929 fn banner_renders_the_csrf_field_with_the_configured_name() {
4930 let mut banner = banner_state("user-9", "admin-1");
4931 banner.csrf_token = "tok-123".to_owned();
4932 banner.csrf_form_field = "authenticity_token".to_owned();
4933 let html = impersonation_banner(&banner).into_string();
4934 assert!(
4935 html.contains(r#"name="authenticity_token" value="tok-123""#),
4936 "{html}"
4937 );
4938 }
4939
4940 #[test]
4941 fn banner_falls_back_to_the_default_csrf_field_name() {
4942 let mut banner = banner_state("user-9", "admin-1");
4943 banner.csrf_token = "tok-123".to_owned();
4944 let html = impersonation_banner(&banner).into_string();
4945 assert!(html.contains(r#"name="_csrf" value="tok-123""#), "{html}");
4946 }
4947
4948 #[test]
4949 fn banner_action_survives_a_trailing_slash_on_the_prefix() {
4950 let mut banner = banner_state("user-9", "admin-1");
4951 banner.admin_prefix = "/back-office/".to_owned();
4952 let html = impersonation_banner(&banner).into_string();
4953 assert!(
4954 html.contains(r#"action="/back-office/impersonate/stop""#),
4955 "{html}"
4956 );
4957 }
4958
4959 #[test]
4960 fn banner_carries_return_to_only_when_set() {
4961 let plain = impersonation_banner(&banner_state("user-9", "admin-1")).into_string();
4962 assert!(!plain.contains("return_to"), "{plain}");
4963
4964 let with_return =
4965 impersonation_banner(&banner_state("user-9", "admin-1").returning_to("/dashboard"))
4966 .into_string();
4967 assert!(
4968 with_return.contains(r#"name="return_to" value="/dashboard""#),
4969 "{with_return}"
4970 );
4971 }
4972
4973 #[test]
4974 fn banner_escapes_the_user_ids_it_renders() {
4975 let html = impersonation_banner(&banner_state("<script>alert(1)</script>", "admin-1"))
4976 .into_string();
4977 assert!(!html.contains("<script>"), "{html}");
4978 assert!(html.contains("<script>"), "{html}");
4979 }
4980
4981 #[test]
4982 fn the_admin_layout_ships_the_banner_styles() {
4983 assert!(
4984 IMPERSONATION_BANNER_CSS.contains(".autumn-impersonation-banner"),
4985 "{IMPERSONATION_BANNER_CSS}"
4986 );
4987 }
4988
4989 fn render_layout(active_slug: Option<&str>) -> String {
4992 let registry = AdminRegistry::new();
4993 admin_layout(
4994 &AdminChrome {
4995 csrf_token: "tok",
4996 show_config: true,
4997 ..test_chrome(®istry)
4998 },
4999 active_slug,
5000 "Title",
5001 &html! {},
5002 )
5003 .into_string()
5004 }
5005
5006 #[test]
5007 fn admin_layout_dashboard_active_has_aria_current() {
5008 let html = render_layout(None);
5009 assert_eq!(html.matches(r#"aria-current="page""#).count(), 1, "{html}");
5010 assert!(
5011 html.contains(r#"href="/admin" class="autumn-active" aria-current="page""#),
5012 "{html}"
5013 );
5014 }
5015
5016 #[test]
5017 fn admin_layout_jobs_active_has_aria_current() {
5018 let html = render_layout(Some(JOBS_NAV_SLUG));
5019 assert_eq!(html.matches(r#"aria-current="page""#).count(), 1, "{html}");
5020 assert!(
5021 html.contains(r#"href="/admin/jobs" class="autumn-active" aria-current="page""#),
5022 "{html}"
5023 );
5024 }
5025
5026 #[test]
5027 fn admin_layout_runtime_config_active_has_aria_current() {
5028 let html = render_layout(Some(RUNTIME_CONFIG_NAV_SLUG));
5029 assert_eq!(html.matches(r#"aria-current="page""#).count(), 1, "{html}");
5030 assert!(
5031 html.contains(r#"href="/admin/config" class="autumn-active" aria-current="page""#),
5032 "{html}"
5033 );
5034 }
5035
5036 #[test]
5037 fn admin_layout_no_active_slug_has_no_dashboard_aria_current() {
5038 let html = render_layout(Some(JOBS_NAV_SLUG));
5042 assert!(
5043 !html.contains(r#"href="/admin" class="autumn-active""#),
5044 "dashboard must not be active: {html}"
5045 );
5046 }
5047
5048 use crate::registry::tests::DummyModel;
5051
5052 fn render_layout_with_registry(registry: &AdminRegistry, active_slug: Option<&str>) -> String {
5053 admin_layout(
5054 &AdminChrome {
5055 csrf_token: "tok",
5056 show_config: true,
5057 ..test_chrome(registry)
5058 },
5059 active_slug,
5060 "Title",
5061 &html! {},
5062 )
5063 .into_string()
5064 }
5065
5066 #[test]
5067 fn admin_layout_renders_nav_bar_sidebar() {
5068 let html = render_layout(None);
5069 assert!(html.contains("autumn-nav--sidebar"), "{html}");
5070 assert!(
5071 html.contains(r#"class="autumn-nav autumn-nav--sidebar admin-sidebar""#),
5072 "{html}"
5073 );
5074 assert!(html.contains(r#"aria-label="Admin navigation""#), "{html}");
5075 }
5076
5077 #[test]
5078 fn admin_layout_brand_uses_nav_brand_class() {
5079 let html = render_layout(None);
5080 assert!(html.contains("autumn-nav__brand"), "{html}");
5081 assert!(html.contains("🍂 Autumn Admin"), "{html}");
5082 assert!(!html.contains(r#"class="admin-logo""#), "{html}");
5083 }
5084
5085 #[test]
5086 fn admin_layout_sections_render_as_nav_sections_without_models() {
5087 let html = render_layout(None);
5088 assert!(
5089 html.contains(r#"<li class="autumn-nav__section">System</li>"#),
5090 "{html}"
5091 );
5092 assert!(!html.contains("Models"), "{html}");
5093 }
5094
5095 #[test]
5096 fn admin_layout_sections_render_as_nav_sections_with_models() {
5097 let mut registry = AdminRegistry::new();
5098 registry.register(DummyModel {
5099 slug: "projects",
5100 name: "Projects",
5101 });
5102 let html = render_layout_with_registry(®istry, None);
5103 assert!(
5104 html.contains(r#"<li class="autumn-nav__section">Models</li>"#),
5105 "{html}"
5106 );
5107 assert!(
5108 html.contains(r#"<li class="autumn-nav__section">System</li>"#),
5109 "{html}"
5110 );
5111 assert!(html.contains(r#"href="/admin/projects""#), "{html}");
5112 }
5113
5114 #[test]
5115 fn admin_layout_actuator_link_is_plain() {
5116 let html = render_layout(None);
5117 assert!(html.contains(r#"href="/actuator/ui""#), "{html}");
5118 assert_eq!(html.matches(r#"aria-current="page""#).count(), 1, "{html}");
5121 }
5122
5123 #[test]
5124 fn admin_layout_has_no_hand_rolled_nav_markup() {
5125 let html = render_layout(None);
5126 assert!(!html.contains(r#"class="admin-nav""#), "{html}");
5127 assert!(!html.contains("admin-logo"), "{html}");
5128 }
5129
5130 #[test]
5131 fn admin_layout_loads_nav_bar_widget_runtime() {
5132 let html = render_layout(None);
5136 let widgets_src = format!(
5137 r#"src="{}""#,
5138 autumn_web::assets::asset_url("js/autumn-widgets.js")
5139 );
5140 assert!(html.contains(&widgets_src), "{html}");
5141 }
5142
5143 #[test]
5144 fn admin_sidebar_toggle_stays_hidden() {
5145 assert!(ADMIN_CSS.contains(".autumn-nav__toggle"), "{ADMIN_CSS}");
5150 }
5151
5152 #[test]
5155 fn admin_layout_renders_flash_via_shared_helper() {
5156 use autumn_web::flash::FlashLevel;
5157
5158 let registry = AdminRegistry::new();
5159 let messages = [
5160 FlashMessage {
5161 level: FlashLevel::Success,
5162 message: "Saved!".into(),
5163 },
5164 FlashMessage {
5165 level: FlashLevel::Error,
5166 message: "Boom".into(),
5167 },
5168 ];
5169 let html = admin_layout(
5170 &AdminChrome {
5171 csrf_token: "tok",
5172 messages: &messages,
5173 show_config: true,
5174 ..test_chrome(®istry)
5175 },
5176 None,
5177 "Title",
5178 &html! {},
5179 )
5180 .into_string();
5181
5182 assert!(html.contains("autumn-flash-group"), "{html}");
5185 assert!(html.contains("autumn-flash--success"), "{html}");
5186 assert!(html.contains("autumn-flash--error"), "{html}");
5187 assert!(html.contains("Saved!") && html.contains("Boom"), "{html}");
5188
5189 assert!(html.contains(r#"role="status""#), "{html}");
5192 assert!(html.contains(r#"aria-live="polite""#), "{html}");
5193 assert!(html.contains(r#"role="alert""#), "{html}");
5194 assert!(html.contains(r#"aria-live="assertive""#), "{html}");
5195
5196 assert!(!html.contains("flash flash-"), "{html}");
5198
5199 assert!(html.contains(".autumn-flash-group{"), "{html}");
5202 }
5203}