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