1use std::sync::Arc;
22
23use bytes::Bytes;
24use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
25use http_body_util::{BodyExt, Full};
26
27use crate::error::Error;
28use crate::http::{Request, Response};
29use crate::orm::{Db, Model};
30use crate::router::Router;
31
32pub use crate::http::FormData;
36
37pub mod admin_form_bridge;
38pub mod admin_generator;
39pub mod audit;
40pub mod auto_form;
41pub mod design;
42pub mod entry_builder;
43pub mod form;
44pub mod intelligence;
45pub mod layout;
46pub mod persistence;
47pub mod rbac;
48pub mod relations;
49pub mod schema_cache;
50pub mod schema_introspect;
51pub mod suggestions;
52pub mod templating;
53pub mod ui;
54
55#[cfg(test)]
56mod admin_intelligence_tests;
57#[cfg(test)]
58mod relations_tests;
59#[cfg(test)]
60mod suggestions_tests;
61
62#[non_exhaustive]
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum FieldType {
71 I32,
72 I64,
73 String,
74 Bool,
75 DateTime,
77}
78
79#[derive(Debug, Clone, Copy)]
93pub struct AdminField {
94 pub name: &'static str,
95 pub ty: FieldType,
96 pub editable: bool,
97 pub nullable: bool,
98 pub relation: Option<AdminRelation>,
99}
100
101#[derive(Debug, Clone, Copy)]
111pub struct AdminRelation {
112 pub kind: crate::schema::RelationKind,
116 pub model: &'static str,
119 pub display_field: Option<&'static str>,
123}
124
125pub trait AdminModel: Model {
126 const ADMIN_NAME: &'static str;
127 const DISPLAY_NAME: &'static str;
128 const FIELDS: &'static [AdminField];
129
130 fn field_display(&self, name: &str) -> Option<String>;
131 fn from_form(form: &FormData, id: Option<i64>) -> Result<Self, Error>;
132
133 fn singular_name() -> &'static str {
137 Self::DISPLAY_NAME
138 }
139}
140
141#[derive(Debug, Clone)]
152pub struct AdminEntry {
153 pub admin_name: &'static str,
154 pub display_name: &'static str,
155 pub singular_name: &'static str,
156 pub table: &'static str,
157 pub fields: &'static [AdminField],
158 pub core: bool,
159}
160
161pub const USER_FIELDS: &[AdminField] = &[
168 AdminField {
169 name: "id",
170 ty: FieldType::I64,
171 editable: false,
172 nullable: false,
173 relation: None,
174 },
175 AdminField {
176 name: "email",
177 ty: FieldType::String,
178 editable: true,
179 nullable: false,
180 relation: None,
181 },
182 AdminField {
183 name: "password_hash",
184 ty: FieldType::String,
185 editable: false,
186 nullable: false,
187 relation: None,
188 },
189 AdminField {
190 name: "is_active",
191 ty: FieldType::Bool,
192 editable: true,
193 nullable: false,
194 relation: None,
195 },
196 AdminField {
197 name: "role",
198 ty: FieldType::String,
199 editable: true,
200 nullable: false,
201 relation: None,
202 },
203 AdminField {
204 name: "created_at",
205 ty: FieldType::DateTime,
206 editable: false,
207 nullable: false,
208 relation: None,
209 },
210];
211
212pub(crate) const USER_ENTRY: AdminEntry = AdminEntry {
215 admin_name: "users",
216 display_name: "Users",
217 singular_name: "User",
218 table: "rustio_users",
219 fields: USER_FIELDS,
220 core: true,
221};
222
223const ADMIN_CSS_BUNDLE: &str = include_str!("../assets/admin.css");
231
232const ADMIN_CSS_VER: usize = ADMIN_CSS_BUNDLE.len();
240
241const ADMIN_FAVICON_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#B84318"/><text x="16" y="22" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="20" font-weight="700" fill="#ffffff" text-anchor="middle">R</text></svg>"##;
248
249fn svg(path: &str) -> String {
254 format!(
255 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{path}</svg>"#
256 )
257}
258
259fn icon_layers() -> String {
260 svg(
261 r#"<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/>"#,
262 )
263}
264
265fn icon_dashboard() -> String {
266 svg(
267 r#"<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>"#,
268 )
269}
270
271fn icon_plus() -> String {
272 svg(r#"<path d="M5 12h14"/><path d="M12 5v14"/>"#)
273}
274
275fn icon_search() -> String {
276 svg(r#"<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>"#)
277}
278
279fn icon_pencil() -> String {
280 svg(
281 r#"<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/>"#,
282 )
283}
284
285fn icon_trash() -> String {
286 svg(
287 r#"<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>"#,
288 )
289}
290
291fn icon_chevron_right() -> String {
292 svg(r#"<polyline points="9 18 15 12 9 6"/>"#)
293}
294
295fn icon_logout() -> String {
296 svg(
297 r#"<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>"#,
298 )
299}
300
301fn icon_shield_alert() -> String {
302 svg(
303 r#"<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>"#,
304 )
305}
306
307fn icon_triangle_alert() -> String {
308 svg(
309 r#"<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>"#,
310 )
311}
312
313fn icon_inbox() -> String {
314 svg(
315 r#"<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>"#,
316 )
317}
318
319fn icon_arrow_left() -> String {
320 svg(r#"<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>"#)
321}
322
323fn icon_activity() -> String {
324 svg(
325 r#"<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.5.5 0 0 1-.95 0L9.24 2.18a.5.5 0 0 0-.95 0L5.94 10.54A2 2 0 0 1 4.01 12H2"/>"#,
326 )
327}
328
329fn icon_home() -> String {
330 svg(
331 r#"<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>"#,
332 )
333}
334
335fn icon_bell() -> String {
336 svg(
337 r#"<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>"#,
338 )
339}
340
341fn icon_mail() -> String {
342 svg(
343 r#"<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>"#,
344 )
345}
346
347type ModelRegistrar = Box<dyn FnOnce(Router, &Db, Arc<Vec<AdminEntry>>) -> Router + Send + Sync>;
352
353pub struct Admin {
356 entries: Vec<AdminEntry>,
357 registrars: Vec<ModelRegistrar>,
358}
359
360impl Admin {
361 pub fn new() -> Self {
362 Self {
363 entries: vec![USER_ENTRY.clone()],
364 registrars: Vec::new(),
365 }
366 }
367
368 pub fn model<T: AdminModel>(mut self) -> Self {
370 self.entries.push(AdminEntry {
371 admin_name: T::ADMIN_NAME,
372 display_name: T::DISPLAY_NAME,
373 singular_name: T::singular_name(),
374 table: T::TABLE,
375 fields: T::FIELDS,
376 core: false,
377 });
378 self.registrars.push(Box::new(|router, db, entries| {
379 mount_model::<T>(router, db, entries)
380 }));
381 self
382 }
383
384 pub fn len(&self) -> usize {
386 self.entries.iter().filter(|e| !e.core).count()
387 }
388
389 pub fn is_empty(&self) -> bool {
390 self.len() == 0
391 }
392
393 pub fn entries(&self) -> &[AdminEntry] {
397 &self.entries
398 }
399
400 pub fn register(self, mut router: Router, db: &Db) -> Router {
402 let entries = Arc::new(self.entries);
403
404 let err_entries = entries.clone();
408 router = router.wrap(move |req, next| {
409 let entries = err_entries.clone();
410 async move {
411 let is_admin = req.uri().path().starts_with("/admin");
412 if !is_admin {
413 return next.run(req).await;
414 }
415 let path = req.uri().path();
419 if path == "/admin/assets/admin.css" || path == "/admin/assets/favicon.svg" {
420 return next.run(req).await;
421 }
422 let user_email = req
423 .ctx()
424 .get::<crate::auth::Identity>()
425 .map(|i| i.email.clone());
426 let csrf = req
427 .ctx()
428 .get::<crate::auth::CsrfToken>()
429 .map(|t| t.0.clone());
430
431 let res = next.run(req).await;
432 match res {
433 Err(Error::NotFound) => Ok(admin_not_found_response(
434 &entries,
435 user_email.as_deref(),
436 csrf.as_deref(),
437 )),
438 Err(Error::Internal(msg)) => {
439 let req_id = new_request_id();
440 eprintln!("admin 500 [{req_id}]: {msg}");
441 Ok(admin_server_error_response(
442 &entries,
443 user_email.as_deref(),
444 csrf.as_deref(),
445 &req_id,
446 ))
447 }
448 other => other,
449 }
450 }
451 });
452
453 router = router.get("/admin/assets/admin.css", |_req, _params| async move {
457 Ok::<Response, Error>(admin_css_response())
458 });
459 router = router.get("/admin/assets/favicon.svg", |_req, _params| async move {
463 Ok::<Response, Error>(admin_favicon_response())
464 });
465
466 for &(path, content_type, bytes) in crate::admin::templating::BUNDLED_ASSETS {
471 let full_path = format!("/admin/static/{path}");
472 router = router.get(&full_path, move |_req, _params| async move {
473 Ok::<Response, Error>(bundled_asset_response(bytes, content_type))
474 });
475 }
476
477 let admin_new_registry = std::sync::Arc::new({
481 let mut reg = crate::admin::admin_form_bridge::AdminRegistry::new();
482 reg.register("users", crate::admin::layout::new_user_admin);
483 reg
484 });
485
486 let index_db = db.clone();
491 let index_registry = admin_new_registry.clone();
492 let index_entries = entries.clone();
493 router = router.get("/admin", move |req, _params| {
494 let db = index_db.clone();
495 let registry = index_registry.clone();
496 let legacy_entries = index_entries.clone();
497 async move {
498 if let Err(resp) = admin_guard(req.ctx()) {
499 return Ok(resp);
500 }
501 let identity = crate::auth::identity(req.ctx()).cloned();
505 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
506 let html = crate::admin::layout::dashboard_render(
507 &db,
508 ®istry,
509 legacy_entries.as_slice(),
510 identity.as_ref(),
511 csrf.as_deref(),
512 )
513 .await;
514 Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
515 }
516 });
517 let login_db = db.clone();
524 router = router.post("/admin/login", move |req, _params| {
525 let db = login_db.clone();
526 async move { handle_login(req, &db).await }
527 });
528 router = router.get("/admin/login", |_req, _params| async move {
536 Ok::<Response, Error>(login_page(200, None, None))
537 });
538 let logout_db = db.clone();
539 router = router.post("/admin/logout", move |req, _params| {
540 let db = logout_db.clone();
541 async move { handle_logout(req, &db).await }
542 });
543 router = router.get("/admin/logout", move |req, _params| async move {
544 let signed_in = req.ctx().get::<crate::auth::Identity>().is_some();
545 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
546 Ok::<Response, Error>(logout_confirmation_response(signed_in, csrf.as_deref()))
547 });
548
549 let pw_get_entries = entries.clone();
552 let pw_get_db = db.clone();
553 let pw_get_registry = admin_new_registry.clone();
554 router = router.get("/admin/password_change", move |req, _params| {
555 let legacy_entries = pw_get_entries.clone();
556 let db = pw_get_db.clone();
557 let registry = pw_get_registry.clone();
558 async move {
559 if let Err(resp) = admin_guard(req.ctx()) {
560 return Ok(resp);
561 }
562 let identity = crate::auth::identity(req.ctx()).cloned();
563 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
564 let html = crate::admin::layout::password_change_render(
565 &db,
566 ®istry,
567 &legacy_entries,
568 identity.as_ref(),
569 csrf.as_deref(),
570 None,
571 )
572 .await;
573 Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
574 }
575 });
576 let pw_post_entries = entries.clone();
577 let pw_post_db = db.clone();
578 let pw_post_registry = admin_new_registry.clone();
579 router = router.post("/admin/password_change", move |req, _params| {
580 let legacy_entries = pw_post_entries.clone();
581 let db = pw_post_db.clone();
582 let registry = pw_post_registry.clone();
583 async move {
584 if let Err(resp) = admin_guard(req.ctx()) {
585 return Ok(resp);
586 }
587 handle_password_change_post(req, &db, ®istry, &legacy_entries).await
588 }
589 });
590 let pw_done_entries = entries.clone();
591 let pw_done_db = db.clone();
592 let pw_done_registry = admin_new_registry.clone();
593 router = router.get("/admin/password_change/done", move |req, _params| {
594 let legacy_entries = pw_done_entries.clone();
595 let db = pw_done_db.clone();
596 let registry = pw_done_registry.clone();
597 async move {
598 if let Err(resp) = admin_guard(req.ctx()) {
599 return Ok(resp);
600 }
601 let identity = crate::auth::identity(req.ctx()).cloned();
602 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
603 let html = crate::admin::layout::password_change_done_render(
604 &db,
605 ®istry,
606 &legacy_entries,
607 identity.as_ref(),
608 csrf.as_deref(),
609 )
610 .await;
611 Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
612 }
613 });
614
615 let profile_entries = entries.clone();
617 let profile_db = db.clone();
618 let profile_registry = admin_new_registry.clone();
619 router = router.get("/admin/profile", move |req, _params| {
620 let legacy_entries = profile_entries.clone();
621 let db = profile_db.clone();
622 let registry = profile_registry.clone();
623 async move {
624 if let Err(resp) = admin_guard(req.ctx()) {
625 return Ok(resp);
626 }
627 let identity = crate::auth::identity(req.ctx()).cloned();
628 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
629 let user = match identity.as_ref() {
630 Some(id) => crate::auth::user::find_by_id(&db, id.user_id).await?,
631 None => None,
632 };
633 let html = crate::admin::layout::profile_render(
634 &db,
635 ®istry,
636 &legacy_entries,
637 identity.as_ref(),
638 user.as_ref(),
639 csrf.as_deref(),
640 )
641 .await;
642 Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
643 }
644 });
645
646 let actions_entries = entries.clone();
648 let actions_db = db.clone();
649 let actions_registry = admin_new_registry.clone();
650 router = router.get("/admin/actions", move |req, _params| {
651 let legacy_entries = actions_entries.clone();
652 let db = actions_db.clone();
653 let registry = actions_registry.clone();
654 async move {
655 if let Err(resp) = admin_guard(req.ctx()) {
656 return Ok(resp);
657 }
658 let query = req.query();
659 let model_filter = query
660 .get("model")
661 .map(str::trim)
662 .filter(|s| !s.is_empty())
663 .map(String::from);
664 let action_filter = query
665 .get("action")
666 .map(str::trim)
667 .filter(|s| !s.is_empty())
668 .map(String::from);
669 let actions =
670 audit::recent(&db, 200, model_filter.as_deref(), action_filter.as_deref())
671 .await?;
672 let identity = crate::auth::identity(req.ctx()).cloned();
673 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
674 let html = crate::admin::layout::actions_render(
675 &db,
676 ®istry,
677 &legacy_entries,
678 identity.as_ref(),
679 csrf.as_deref(),
680 &actions,
681 model_filter.as_deref(),
682 action_filter.as_deref(),
683 )
684 .await;
685 Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
686 }
687 });
688
689 let sugg_get_entries = entries.clone();
693 let sugg_get_db = db.clone();
694 let sugg_get_registry = admin_new_registry.clone();
695 router = router.get("/admin/suggestions/:admin/:field", move |req, params| {
696 let legacy_entries = sugg_get_entries.clone();
697 let db = sugg_get_db.clone();
698 let registry = sugg_get_registry.clone();
699 async move {
700 if let Err(resp) = admin_guard(req.ctx()) {
701 return Ok(resp);
702 }
703 let admin_name = params.get("admin").unwrap_or("").to_string();
704 let field = params.get("field").unwrap_or("").to_string();
705 let identity = crate::auth::identity(req.ctx()).cloned();
706 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
707 Ok::<Response, Error>(
708 suggestion_review_response(
709 &db,
710 ®istry,
711 &legacy_entries,
712 identity.as_ref(),
713 csrf.as_deref(),
714 &admin_name,
715 &field,
716 None,
717 )
718 .await,
719 )
720 }
721 });
722 let sugg_post_entries = entries.clone();
723 let sugg_post_db = db.clone();
724 let sugg_post_registry = admin_new_registry.clone();
725 router = router.post("/admin/suggestions/:admin/:field", move |req, params| {
726 let legacy_entries = sugg_post_entries.clone();
727 let db = sugg_post_db.clone();
728 let registry = sugg_post_registry.clone();
729 async move {
730 if let Err(resp) = admin_guard(req.ctx()) {
731 return Ok(resp);
732 }
733 let admin_name = params.get("admin").unwrap_or("").to_string();
734 let field = params.get("field").unwrap_or("").to_string();
735 let (_, body, ctx) = req.into_parts();
736 let form = read_form_from_parts(body).await?;
737 require_csrf(&ctx, &form)?;
738 let identity = crate::auth::identity(&ctx).cloned();
739 let csrf = ctx_csrf(&ctx).map(str::to_string);
740 Ok::<Response, Error>(
741 suggestion_apply_response(
742 &db,
743 ®istry,
744 &legacy_entries,
745 identity.as_ref(),
746 csrf.as_deref(),
747 &admin_name,
748 &field,
749 )
750 .await,
751 )
752 }
753 });
754
755 router = router.post("/admin/schema/reload", move |req, _params| async move {
758 if let Err(resp) = admin_guard(req.ctx()) {
759 return Ok(resp);
760 }
761 let (_, body, ctx) = req.into_parts();
762 let form = read_form_from_parts(body).await?;
763 require_csrf(&ctx, &form)?;
764 let redirect_url = match schema_cache::refresh() {
768 Ok(_) => "/admin?schema_reload=ok",
769 Err(_) => "/admin?schema_reload=err",
770 };
771 Ok::<Response, Error>(with_admin_headers(redirect(redirect_url)))
772 });
773
774 {
784 let db = db.clone();
785 let registry = admin_new_registry.clone();
786 let model_entries = entries.clone();
787 router =
788 router.get("/admin/:model", move |req, params| {
789 let db = db.clone();
790 let registry = registry.clone();
791 let legacy_entries = model_entries.clone();
792 async move {
793 admin_model_index_get(&db, ®istry, &legacy_entries, req, params).await
794 }
795 });
796 }
797
798 {
805 let db = db.clone();
806 let registry = admin_new_registry.clone();
807 let form_new_entries = entries.clone();
808 router = router.get("/admin/:model/new", move |req, params| {
809 let db = db.clone();
810 let registry = registry.clone();
811 let legacy_entries = form_new_entries.clone();
812 async move {
813 admin_model_form_get(&db, ®istry, &legacy_entries, req, params, None).await
814 }
815 });
816 }
817 {
818 let db = db.clone();
819 let registry = admin_new_registry.clone();
820 let form_edit_entries = entries.clone();
821 router = router.get("/admin/:model/:id/edit", move |req, params| {
822 let db = db.clone();
823 let registry = registry.clone();
824 let legacy_entries = form_edit_entries.clone();
825 async move {
826 let id = params.get("id").map(str::to_string);
827 admin_model_form_get(
828 &db,
829 ®istry,
830 &legacy_entries,
831 req,
832 params,
833 id.as_deref(),
834 )
835 .await
836 }
837 });
838 }
839
840 {
842 let db = db.clone();
843 let registry = admin_new_registry.clone();
844 let create_entries = entries.clone();
845 router = router.post("/admin/:model/new", move |req, params| {
846 let db = db.clone();
847 let registry = registry.clone();
848 let legacy_entries = create_entries.clone();
849 async move {
850 admin_model_create_post(&db, ®istry, &legacy_entries, req, params).await
851 }
852 });
853 }
854 {
855 let db = db.clone();
856 let registry = admin_new_registry.clone();
857 let update_entries = entries.clone();
858 router = router.post("/admin/:model/:id/edit", move |req, params| {
859 let db = db.clone();
860 let registry = registry.clone();
861 let legacy_entries = update_entries.clone();
862 async move {
863 admin_model_update_post(&db, ®istry, &legacy_entries, req, params).await
864 }
865 });
866 }
867 {
868 let db = db.clone();
869 let registry = admin_new_registry.clone();
870 let delete_entries = entries.clone();
871 router = router.post("/admin/:model/:id/delete", move |req, params| {
872 let db = db.clone();
873 let registry = registry.clone();
874 let legacy_entries = delete_entries.clone();
875 async move {
876 admin_model_delete_post(&db, ®istry, &legacy_entries, req, params).await
877 }
878 });
879 }
880
881 for registrar in self.registrars {
882 router = registrar(router, db, entries.clone());
883 }
884 router
885 }
886}
887
888impl Default for Admin {
889 fn default() -> Self {
890 Self::new()
891 }
892}
893
894pub fn register<T>(router: Router, db: &Db) -> Router
896where
897 T: AdminModel + Model,
898{
899 Admin::new().model::<T>().register(router, db)
900}
901
902fn mount_model<T>(mut router: Router, db: &Db, entries: Arc<Vec<AdminEntry>>) -> Router
903where
904 T: AdminModel + Model,
905{
906 let base = format!("/admin/{}", T::ADMIN_NAME);
907 let create_path = format!("{base}/create");
908 let edit_path = format!("{base}/:id/edit");
909 let delete_path = format!("{base}/:id/delete");
910 let history_path = format!("{base}/:id/history");
911 let bulk_path = format!("{base}/bulk_action");
912
913 let list_db = db.clone();
915 let list_entries = entries.clone();
916 router = router.get(&base, move |req, _params| {
917 let db = list_db.clone();
918 let entries = list_entries.clone();
919 async move {
920 if let Err(resp) = admin_guard(req.ctx()) {
921 return Ok(resp);
922 }
923 let query = req.query();
924 let q = query
925 .get("q")
926 .map(str::trim)
927 .filter(|s| !s.is_empty())
928 .map(String::from);
929 let status = query
930 .get("status")
931 .map(str::trim)
932 .filter(|s| !s.is_empty())
933 .map(String::from);
934 let priority = query
935 .get("priority")
936 .map(str::trim)
937 .filter(|s| !s.is_empty())
938 .map(String::from);
939 let sort = query
943 .get("sort")
944 .map(str::trim)
945 .filter(|s| !s.is_empty())
946 .filter(|s| SORT_OPTIONS.iter().any(|(v, _)| *v == *s))
947 .map(String::from);
948
949 let visible_columns: Vec<&'static str> = default_list_columns::<T>();
953
954 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
955 let all_items = T::all(&db).await?;
956 let total = all_items.len();
957
958 let status_options = distinct_values::<T>(&all_items, "status");
962 let priority_options = distinct_values::<T>(&all_items, "priority");
963
964 let registry = current_registry();
969 let relation_filter_states = if registry.is_empty() {
970 Vec::new()
971 } else {
972 build_relation_filters::<T>(&db, ®istry, &query).await
973 };
974
975 let mut filtered: Vec<&T> = all_items
979 .iter()
980 .filter(|item| {
981 if let Some(qs) = &q {
982 if !matches_query::<T>(item, qs) {
983 return false;
984 }
985 }
986 if let Some(s) = &status {
987 let v = item.field_display("status").unwrap_or_default();
988 if &v != s {
989 return false;
990 }
991 }
992 for rel in &relation_filter_states {
997 if let Some(wanted) = rel.current_value {
998 let actual = item
999 .field_display(&rel.field_name)
1000 .and_then(|s| s.parse::<i64>().ok());
1001 if actual != Some(wanted) {
1002 return false;
1003 }
1004 }
1005 }
1006 if let Some(p) = &priority {
1007 let v = item.field_display("priority").unwrap_or_default();
1008 if &v != p {
1009 return false;
1010 }
1011 }
1012 true
1013 })
1014 .collect();
1015
1016 match sort.as_deref() {
1020 Some("oldest") | Some("id_asc") => filtered.sort_by_key(|i| i.id()),
1021 Some("id_desc") => filtered.sort_by_key(|i| std::cmp::Reverse(i.id())),
1022 Some("newest") | None => filtered.sort_by_key(|i| std::cmp::Reverse(i.id())),
1023 _ => {}
1024 }
1025
1026 let filters = ListFilters {
1027 q: q.as_deref(),
1028 status: status.as_deref(),
1029 status_options: &status_options,
1030 priority: priority.as_deref(),
1031 priority_options: &priority_options,
1032 sort: sort.as_deref(),
1033 relation_filters: &relation_filter_states,
1034 visible_columns: &visible_columns,
1035 };
1036
1037 let fk_labels = if registry.is_empty() {
1043 FkLabels::new()
1044 } else {
1045 fetch_fk_labels::<T>(&db, &filtered, ®istry).await
1046 };
1047 let cell_ctx = CellCtx {
1048 registry: ®istry,
1049 fk_labels: &fk_labels,
1050 };
1051 Ok::<Response, Error>(list_response::<T>(
1052 shell, &filtered, total, filters, &cell_ctx,
1053 ))
1054 }
1055 });
1056
1057 let create_entries = entries.clone();
1059 let create_form_db = db.clone();
1060 router = router.get(&create_path, move |req, _params| {
1061 let entries = create_entries.clone();
1062 let db = create_form_db.clone();
1063 async move {
1064 if let Err(resp) = admin_guard(req.ctx()) {
1065 return Ok(resp);
1066 }
1067 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1068 let cell_ctx = CellCtx::empty();
1071 let inverse_counts = std::collections::HashMap::new();
1072 let registry = current_registry();
1073 let form_options = if registry.is_empty() {
1074 FormRelationOptions::new()
1075 } else {
1076 fetch_form_relation_options::<T>(&db, ®istry).await
1077 };
1078 Ok::<Response, Error>(form_response::<T>(
1079 shell,
1080 FormMode::Create,
1081 &cell_ctx,
1082 &inverse_counts,
1083 &form_options,
1084 ))
1085 }
1086 });
1087
1088 let create_db = db.clone();
1089 router = router.post(&create_path, move |req, _params| {
1090 let db = create_db.clone();
1091 async move {
1092 if let Err(resp) = admin_guard(req.ctx()) {
1093 return Ok(resp);
1094 }
1095 let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1098 let (_, body, ctx) = req.into_parts();
1099 let form = read_form_from_parts(body).await?;
1100 require_csrf(&ctx, &form)?;
1101 let user_id = ctx
1102 .get::<crate::auth::Identity>()
1103 .map(|i| i.user_id)
1104 .unwrap_or(0);
1105
1106 let item = T::from_form(&form, None)?;
1107 let primary = primary_string_value::<T>(&item);
1108 let new_id = item.create(&db).await?;
1109
1110 audit::record(
1111 &db,
1112 audit::LogEntry {
1113 user_id,
1114 action_type: audit::ActionType::Create,
1115 model_name: T::ADMIN_NAME,
1116 object_id: new_id,
1117 ip_address: peer_ip.as_deref(),
1118 summary: audit_summary(
1119 audit::ActionType::Create,
1120 T::singular_name(),
1121 new_id,
1122 &primary,
1123 ),
1124 },
1125 )
1126 .await?;
1127
1128 Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1129 "/admin/{}",
1130 T::ADMIN_NAME
1131 ))))
1132 }
1133 });
1134
1135 let edit_db = db.clone();
1137 let edit_entries = entries.clone();
1138 router = router.get(&edit_path, move |req, params| {
1139 let db = edit_db.clone();
1140 let entries = edit_entries.clone();
1141 async move {
1142 if let Err(resp) = admin_guard(req.ctx()) {
1143 return Ok(resp);
1144 }
1145 let id = parse_id_param(¶ms)?;
1146 let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
1147 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1148 let registry = current_registry();
1152 let items_ref: Vec<&T> = vec![&item];
1153 let fk_labels = if registry.is_empty() {
1154 FkLabels::new()
1155 } else {
1156 fetch_fk_labels::<T>(&db, &items_ref, ®istry).await
1157 };
1158 let inverse_counts = if registry.is_empty() {
1159 std::collections::HashMap::new()
1160 } else {
1161 fetch_inverse_counts(&db, T::singular_name(), id, ®istry).await
1162 };
1163 let cell_ctx = CellCtx {
1164 registry: ®istry,
1165 fk_labels: &fk_labels,
1166 };
1167 let form_options = if registry.is_empty() {
1168 FormRelationOptions::new()
1169 } else {
1170 fetch_form_relation_options::<T>(&db, ®istry).await
1171 };
1172 Ok::<Response, Error>(form_response::<T>(
1173 shell,
1174 FormMode::Edit { id, item: &item },
1175 &cell_ctx,
1176 &inverse_counts,
1177 &form_options,
1178 ))
1179 }
1180 });
1181
1182 let update_db = db.clone();
1183 router = router.post(&edit_path, move |req, params| {
1184 let db = update_db.clone();
1185 async move {
1186 if let Err(resp) = admin_guard(req.ctx()) {
1187 return Ok(resp);
1188 }
1189 let id = parse_id_param(¶ms)?;
1190 let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1191 let (_, body, ctx) = req.into_parts();
1192 let form = read_form_from_parts(body).await?;
1193 require_csrf(&ctx, &form)?;
1194 let user_id = ctx
1195 .get::<crate::auth::Identity>()
1196 .map(|i| i.user_id)
1197 .unwrap_or(0);
1198
1199 let item = T::from_form(&form, Some(id))?;
1200 let primary = primary_string_value::<T>(&item);
1201 item.update(&db).await?;
1202
1203 audit::record(
1204 &db,
1205 audit::LogEntry {
1206 user_id,
1207 action_type: audit::ActionType::Update,
1208 model_name: T::ADMIN_NAME,
1209 object_id: id,
1210 ip_address: peer_ip.as_deref(),
1211 summary: audit_summary(
1212 audit::ActionType::Update,
1213 T::singular_name(),
1214 id,
1215 &primary,
1216 ),
1217 },
1218 )
1219 .await?;
1220
1221 Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1222 "/admin/{}",
1223 T::ADMIN_NAME
1224 ))))
1225 }
1226 });
1227
1228 let delete_confirm_db = db.clone();
1230 let delete_confirm_entries = entries.clone();
1231 router = router.get(&delete_path, move |req, params| {
1232 let db = delete_confirm_db.clone();
1233 let entries = delete_confirm_entries.clone();
1234 async move {
1235 if let Err(resp) = admin_guard(req.ctx()) {
1236 return Ok(resp);
1237 }
1238 let id = parse_id_param(¶ms)?;
1239 let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
1240 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1241 Ok::<Response, Error>(delete_confirmation_response::<T>(shell, id, &item))
1242 }
1243 });
1244
1245 let delete_db = db.clone();
1246 let delete_entries = entries.clone();
1247 router = router.post(&delete_path, move |req, params| {
1248 let db = delete_db.clone();
1249 let entries = delete_entries.clone();
1250 async move {
1251 if let Err(resp) = admin_guard(req.ctx()) {
1252 return Ok(resp);
1253 }
1254 let id = parse_id_param(¶ms)?;
1255 let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1256 let (_, body, ctx) = req.into_parts();
1257 let form = read_form_from_parts(body).await?;
1258 require_csrf(&ctx, &form)?;
1259 let user_id = ctx
1260 .get::<crate::auth::Identity>()
1261 .map(|i| i.user_id)
1262 .unwrap_or(0);
1263
1264 let primary = match T::find(&db, id).await? {
1267 Some(item) => primary_string_value::<T>(&item),
1268 None => String::new(),
1269 };
1270
1271 let registry = current_registry();
1276 if !registry.is_empty() {
1277 let counts = fetch_inverse_counts(&db, T::singular_name(), id, ®istry).await;
1278 let blockers: Vec<(&relations::InverseRelation, i64)> = registry
1279 .has_many(T::singular_name())
1280 .iter()
1281 .filter_map(|inv| {
1282 let key = format!("{}.{}", inv.source_model, inv.source_field);
1283 counts
1284 .get(&key)
1285 .copied()
1286 .filter(|n| *n > 0)
1287 .map(|n| (inv, n))
1288 })
1289 .collect();
1290 if !blockers.is_empty() {
1291 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
1292 return Ok::<Response, Error>(render_delete_blocked_page::<T>(
1293 &shell, id, &primary, &blockers,
1294 ));
1295 }
1296 }
1297
1298 if let Err(e) = T::delete(&db, id).await {
1304 if is_foreign_key_violation(&e) {
1305 let registry = current_registry();
1306 let counts = fetch_inverse_counts(&db, T::singular_name(), id, ®istry).await;
1307 let blockers: Vec<(&relations::InverseRelation, i64)> = registry
1308 .has_many(T::singular_name())
1309 .iter()
1310 .filter_map(|inv| {
1311 let key = format!("{}.{}", inv.source_model, inv.source_field);
1312 counts
1313 .get(&key)
1314 .copied()
1315 .filter(|n| *n > 0)
1316 .map(|n| (inv, n))
1317 })
1318 .collect();
1319 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
1320 return Ok::<Response, Error>(render_delete_blocked_page::<T>(
1321 &shell, id, &primary, &blockers,
1322 ));
1323 }
1324 return Err(e);
1325 }
1326
1327 audit::record(
1328 &db,
1329 audit::LogEntry {
1330 user_id,
1331 action_type: audit::ActionType::Delete,
1332 model_name: T::ADMIN_NAME,
1333 object_id: id,
1334 ip_address: peer_ip.as_deref(),
1335 summary: audit_summary(
1336 audit::ActionType::Delete,
1337 T::singular_name(),
1338 id,
1339 &primary,
1340 ),
1341 },
1342 )
1343 .await?;
1344
1345 Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1346 "/admin/{}",
1347 T::ADMIN_NAME
1348 ))))
1349 }
1350 });
1351
1352 let history_db = db.clone();
1356 let history_entries = entries.clone();
1357 router = router.get(&history_path, move |req, params| {
1358 let db = history_db.clone();
1359 let entries = history_entries.clone();
1360 async move {
1361 if let Err(resp) = admin_guard(req.ctx()) {
1362 return Ok(resp);
1363 }
1364 let id = parse_id_param(¶ms)?;
1365 let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
1366 let actions = audit::for_object(&db, T::ADMIN_NAME, id).await?;
1367 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1368 Ok::<Response, Error>(object_history_response::<T>(shell, id, &item, &actions))
1369 }
1370 });
1371
1372 let bulk_db = db.clone();
1378 let bulk_entries = entries.clone();
1379 router = router.post(&bulk_path, move |req, _params| {
1380 let db = bulk_db.clone();
1381 let entries = bulk_entries.clone();
1382 async move {
1383 if let Err(resp) = admin_guard(req.ctx()) {
1384 return Ok(resp);
1385 }
1386 let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1387 let (_, body, ctx) = req.into_parts();
1388 let form = read_form_from_parts(body).await?;
1389 require_csrf(&ctx, &form)?;
1390 let user_id = ctx
1391 .get::<crate::auth::Identity>()
1392 .map(|i| i.user_id)
1393 .unwrap_or(0);
1394
1395 let action = form.get("action").unwrap_or("").trim().to_string();
1396 let selected_raw = form.get("_selected").unwrap_or("").to_string();
1397 let ids: Vec<i64> = selected_raw
1398 .split(',')
1399 .map(str::trim)
1400 .filter(|s| !s.is_empty())
1401 .filter_map(|s| s.parse::<i64>().ok())
1402 .collect();
1403 let confirmed = form.get("_confirm").map(|v| v == "yes").unwrap_or(false);
1404
1405 if ids.is_empty() || action.is_empty() {
1406 return Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1407 "/admin/{}",
1408 T::ADMIN_NAME
1409 ))));
1410 }
1411
1412 if action != "delete" {
1413 return Err(Error::BadRequest(
1414 format!("Unknown bulk action `{action}`",),
1415 ));
1416 }
1417
1418 if !confirmed {
1421 let mut items: Vec<(i64, String)> = Vec::with_capacity(ids.len());
1422 for id in &ids {
1423 if let Some(item) = T::find(&db, *id).await? {
1424 let primary = primary_string_value::<T>(&item);
1425 items.push((*id, primary));
1426 }
1427 }
1428 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
1429 return Ok::<Response, Error>(bulk_delete_confirmation_response::<T>(
1430 &shell, &items,
1431 ));
1432 }
1433
1434 for id in &ids {
1437 let primary = match T::find(&db, *id).await? {
1438 Some(item) => primary_string_value::<T>(&item),
1439 None => continue, };
1441 T::delete(&db, *id).await?;
1442
1443 let mut summary =
1444 audit_summary(audit::ActionType::Delete, T::singular_name(), *id, &primary);
1445 summary.push_str(" (via bulk action)");
1446
1447 audit::record(
1448 &db,
1449 audit::LogEntry {
1450 user_id,
1451 action_type: audit::ActionType::Delete,
1452 model_name: T::ADMIN_NAME,
1453 object_id: *id,
1454 ip_address: peer_ip.as_deref(),
1455 summary,
1456 },
1457 )
1458 .await?;
1459 }
1460
1461 Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1462 "/admin/{}",
1463 T::ADMIN_NAME
1464 ))))
1465 }
1466 });
1467
1468 router
1469}
1470
1471fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
1472 params
1473 .get("id")
1474 .and_then(|s| s.parse::<i64>().ok())
1475 .ok_or_else(|| Error::BadRequest(String::from("invalid id")))
1476}
1477
1478fn primary_string_value<T: AdminModel>(item: &T) -> String {
1482 T::FIELDS
1483 .iter()
1484 .find(|f| f.editable && matches!(f.ty, FieldType::String))
1485 .and_then(|f| item.field_display(f.name))
1486 .filter(|s| !s.is_empty())
1487 .unwrap_or_default()
1488}
1489
1490fn audit_summary(action: audit::ActionType, singular: &str, id: i64, primary: &str) -> String {
1493 let verb = action.label();
1494 if primary.is_empty() {
1495 format!("{verb} {singular} #{id}")
1496 } else {
1497 format!("{verb} {singular} #{id}: {primary}")
1498 }
1499}
1500
1501pub const MAX_FORM_BODY_BYTES: usize = crate::http::MAX_REQUEST_BODY_BYTES;
1507
1508pub const CSRF_FIELD: &str = "_csrf";
1510
1511fn ctx_csrf(ctx: &crate::context::Context) -> Option<&str> {
1512 ctx.get::<crate::auth::CsrfToken>().map(|t| t.0.as_str())
1513}
1514
1515fn ctx_user_email(ctx: &crate::context::Context) -> Option<&str> {
1516 ctx.get::<crate::auth::Identity>().map(|i| i.email.as_str())
1517}
1518
1519fn csrf_input(csrf: Option<&str>) -> String {
1520 match csrf {
1521 Some(token) if !token.is_empty() => format!(
1522 r#"<input type="hidden" name="{name}" value="{value}">"#,
1523 name = CSRF_FIELD,
1524 value = escape_html(token),
1525 ),
1526 _ => String::new(),
1527 }
1528}
1529
1530fn require_csrf(ctx: &crate::context::Context, form: &FormData) -> Result<(), Error> {
1531 let expected = ctx
1532 .get::<crate::auth::CsrfToken>()
1533 .map(|t| t.0.as_str())
1534 .unwrap_or("");
1535 let provided = form.get(CSRF_FIELD).unwrap_or("");
1536 if !crate::auth::csrf::verify_token(expected, provided) {
1537 return Err(Error::Forbidden);
1538 }
1539 Ok(())
1540}
1541
1542fn with_admin_headers(mut resp: Response) -> Response {
1543 use hyper::header::HeaderValue;
1544 let h = resp.headers_mut();
1545 h.insert("x-frame-options", HeaderValue::from_static("DENY"));
1546 h.insert(
1547 "x-content-type-options",
1548 HeaderValue::from_static("nosniff"),
1549 );
1550 h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
1551 if crate::auth::in_production() {
1552 h.insert(
1553 "strict-transport-security",
1554 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1555 );
1556 }
1557 resp
1558}
1559
1560async fn read_form(req: Request) -> Result<FormData, Error> {
1561 let (_, body, _) = req.into_parts();
1562 read_form_from_parts(body).await
1563}
1564
1565async fn read_form_from_parts(body: hyper::body::Incoming) -> Result<FormData, Error> {
1566 let limited = http_body_util::Limited::new(body, MAX_FORM_BODY_BYTES);
1567 let collected = limited.collect().await.map_err(|e| {
1568 if e.downcast_ref::<http_body_util::LengthLimitError>()
1569 .is_some()
1570 {
1571 Error::PayloadTooLarge
1572 } else {
1573 Error::BadRequest(e.to_string())
1574 }
1575 })?;
1576 let bytes = collected.to_bytes();
1577 let body_str = std::str::from_utf8(&bytes).map_err(|e| Error::BadRequest(e.to_string()))?;
1578 Ok(FormData::parse(body_str))
1579}
1580
1581fn redirect(to: &str) -> Response {
1582 hyper::Response::builder()
1583 .status(303)
1584 .header("location", to)
1585 .body(Full::new(Bytes::new()))
1586 .expect("valid redirect")
1587}
1588
1589fn admin_css_response() -> Response {
1603 use hyper::header::HeaderValue;
1604 let body = ADMIN_CSS_BUNDLE.as_bytes();
1605 let etag = {
1610 let len = body.len();
1611 let head = u32::from_le_bytes([
1612 *body.first().unwrap_or(&0),
1613 *body.get(1).unwrap_or(&0),
1614 *body.get(2).unwrap_or(&0),
1615 *body.get(3).unwrap_or(&0),
1616 ]);
1617 let tail = u32::from_le_bytes([
1618 *body.get(len.saturating_sub(4)).unwrap_or(&0),
1619 *body.get(len.saturating_sub(3)).unwrap_or(&0),
1620 *body.get(len.saturating_sub(2)).unwrap_or(&0),
1621 *body.get(len.saturating_sub(1)).unwrap_or(&0),
1622 ]);
1623 format!("W/\"rio-{len}-{head:x}-{tail:x}\"")
1624 };
1625 let mut resp = hyper::Response::builder()
1626 .status(200)
1627 .header("content-type", "text/css; charset=utf-8")
1628 .header("cache-control", "no-cache, must-revalidate")
1629 .header("etag", etag)
1630 .body(Full::new(Bytes::from_static(ADMIN_CSS_BUNDLE.as_bytes())))
1631 .expect("valid css response");
1632 let h = resp.headers_mut();
1633 h.insert(
1636 "x-content-type-options",
1637 HeaderValue::from_static("nosniff"),
1638 );
1639 resp
1640}
1641
1642fn env_chip_html() -> String {
1652 if crate::auth::in_production() {
1653 r#"<span class="rio-env-chip is-prod">production</span>"#.to_string()
1654 } else {
1655 r#"<span class="rio-env-chip">development</span>"#.to_string()
1656 }
1657}
1658
1659fn bundled_asset_response(bytes: &'static [u8], content_type: &'static str) -> Response {
1665 use hyper::header::HeaderValue;
1666 let etag = {
1667 let len = bytes.len();
1668 let head = u32::from_le_bytes([
1669 *bytes.first().unwrap_or(&0),
1670 *bytes.get(1).unwrap_or(&0),
1671 *bytes.get(2).unwrap_or(&0),
1672 *bytes.get(3).unwrap_or(&0),
1673 ]);
1674 let tail = u32::from_le_bytes([
1675 *bytes.get(len.saturating_sub(4)).unwrap_or(&0),
1676 *bytes.get(len.saturating_sub(3)).unwrap_or(&0),
1677 *bytes.get(len.saturating_sub(2)).unwrap_or(&0),
1678 *bytes.get(len.saturating_sub(1)).unwrap_or(&0),
1679 ]);
1680 format!("W/\"rio-{len}-{head:x}-{tail:x}\"")
1681 };
1682 let mut resp = hyper::Response::builder()
1683 .status(200)
1684 .header("content-type", content_type)
1685 .header("cache-control", "public, max-age=3600")
1686 .header("etag", etag)
1687 .body(Full::new(Bytes::from_static(bytes)))
1688 .expect("valid static asset response");
1689 resp.headers_mut().insert(
1690 "x-content-type-options",
1691 HeaderValue::from_static("nosniff"),
1692 );
1693 resp
1694}
1695
1696fn admin_favicon_response() -> Response {
1697 use hyper::header::HeaderValue;
1698 let mut resp = hyper::Response::builder()
1699 .status(200)
1700 .header("content-type", "image/svg+xml")
1701 .header("cache-control", "public, max-age=86400")
1702 .body(Full::new(Bytes::from_static(ADMIN_FAVICON_SVG.as_bytes())))
1703 .expect("valid favicon response");
1704 resp.headers_mut().insert(
1705 "x-content-type-options",
1706 HeaderValue::from_static("nosniff"),
1707 );
1708 resp
1709}
1710
1711struct Shell<'a> {
1720 entries: &'a [AdminEntry],
1721 active: Option<&'a str>,
1722 user_email: Option<&'a str>,
1723 csrf: Option<&'a str>,
1724}
1725
1726impl<'a> Shell<'a> {
1727 fn from_ctx(
1728 entries: &'a [AdminEntry],
1729 active: Option<&'a str>,
1730 ctx: &'a crate::context::Context,
1731 ) -> Self {
1732 Self {
1733 entries,
1734 active,
1735 user_email: ctx_user_email(ctx),
1736 csrf: ctx_csrf(ctx),
1737 }
1738 }
1739}
1740
1741type Crumb<'a> = (&'a str, Option<&'a str>);
1744
1745fn render_breadcrumbs(crumbs: &[Crumb<'_>]) -> String {
1746 if crumbs.is_empty() {
1747 return String::new();
1748 }
1749 let sep = format!(
1750 r#"<span class="rio-crumb-sep">{}</span>"#,
1751 icon_chevron_right()
1752 );
1753 let mut out = String::from(r#"<nav class="rio-breadcrumbs" aria-label="Breadcrumb">"#);
1754 for (i, (label, href)) in crumbs.iter().enumerate() {
1755 let is_last = i == crumbs.len() - 1;
1756 if i > 0 {
1757 out.push_str(&sep);
1758 }
1759 match (is_last, href) {
1760 (true, _) => {
1761 out.push_str(&format!(
1762 r#"<span class="rio-crumb-current" aria-current="page">{}</span>"#,
1763 escape_html(label),
1764 ));
1765 }
1766 (false, Some(h)) => {
1767 out.push_str(&format!(
1768 r#"<a href="{}">{}</a>"#,
1769 escape_html(h),
1770 escape_html(label),
1771 ));
1772 }
1773 (false, None) => {
1774 out.push_str(&escape_html(label));
1775 }
1776 }
1777 }
1778 out.push_str("</nav>");
1779 out
1780}
1781
1782const NAV_ACTIONS: &str = "__actions";
1786
1787fn humanise_model_label(name: &str) -> String {
1803 if name == "Staffs" {
1806 return "Staff".to_string();
1807 }
1808 if name == "Diagnosis" {
1809 return "Diagnoses".to_string();
1810 }
1811 let mut out = String::with_capacity(name.len() + 4);
1813 for (i, ch) in name.chars().enumerate() {
1814 if i > 0 && ch.is_ascii_uppercase() {
1815 out.push(' ');
1816 }
1817 out.push(ch);
1818 }
1819 out
1820}
1821
1822fn render_sidebar(shell: &Shell<'_>) -> String {
1823 let design = design::Design::global();
1824 let user_facing: Vec<&AdminEntry> = shell.entries.iter().filter(|e| !e.core).collect();
1825
1826 let mut models_html = String::new();
1827 if !user_facing.is_empty() {
1828 models_html.push_str(r#"<div class="rio-nav">"#);
1829 models_html.push_str(r#"<div class="rio-nav-section">Models</div>"#);
1830 for e in &user_facing {
1831 let active_cls = if shell.active == Some(e.admin_name) {
1832 "rio-nav-link is-active"
1833 } else {
1834 "rio-nav-link"
1835 };
1836 models_html.push_str(&format!(
1837 r#"<a class="{cls}" href="/admin/{name}">{icon}<span>{label}</span></a>"#,
1838 cls = active_cls,
1839 name = escape_html(e.admin_name),
1840 icon = icon_layers(),
1841 label = escape_html(&humanise_model_label(e.display_name)),
1842 ));
1843 }
1844 models_html.push_str("</div>");
1845 }
1846
1847 let dashboard_active = if shell.active.is_none() {
1852 "rio-nav-link is-active"
1853 } else {
1854 "rio-nav-link"
1855 };
1856 let actions_active = if shell.active == Some(NAV_ACTIONS) {
1857 "rio-nav-link is-active"
1858 } else {
1859 "rio-nav-link"
1860 };
1861
1862 let logout_form = if shell.csrf.is_some() {
1863 format!(
1864 r#"<form class="rio-sidebar-logout" method="post" action="/admin/logout">
1865{csrf}
1866<button type="submit">{icon}<span>Sign out</span></button>
1867</form>"#,
1868 csrf = csrf_input(shell.csrf),
1869 icon = icon_logout(),
1870 )
1871 } else {
1872 String::new()
1873 };
1874
1875 let email = shell.user_email.unwrap_or("");
1876 let avatar_initial = email
1877 .chars()
1878 .next()
1879 .map(|c| c.to_ascii_uppercase().to_string())
1880 .unwrap_or_else(|| String::from("·"));
1881
1882 let user_block = if shell.user_email.is_some() {
1883 format!(
1887 r#"<a class="rio-sidebar-user" href="/admin/profile" title="Your profile">
1888<span class="rio-avatar">{avatar}</span>
1889<span class="rio-user-email">{email}</span>
1890</a>"#,
1891 avatar = escape_html(&avatar_initial),
1892 email = escape_html(email),
1893 )
1894 } else {
1895 String::new()
1896 };
1897
1898 format!(
1899 r#"<aside class="rio-sidebar">
1900<div class="rio-sidebar-inner">
1901<a class="rio-brand" href="/admin">
1902<span class="rio-brand-mark">{logo}</span>
1903<span class="rio-brand-meta">
1904<span class="rio-brand-name">{project}</span>
1905<span class="rio-brand-label">Admin</span>
1906</span>
1907</a>
1908<nav class="rio-nav">
1909<a class="{dash}" href="/admin">{dash_icon}<span>Dashboard</span></a>
1910<a class="{actions}" href="/admin/actions">{actions_icon}<span>Recent actions</span></a>
1911</nav>
1912{models}
1913<div class="rio-sidebar-footer">
1914{user}
1915{logout}
1916</div>
1917</div>
1918</aside>"#,
1919 logo = escape_html(&design.logo_initial),
1920 project = escape_html(&design.project_name),
1921 dash = dashboard_active,
1922 dash_icon = icon_dashboard(),
1923 actions = actions_active,
1924 actions_icon = icon_activity(),
1925 models = models_html,
1926 user = user_block,
1927 logout = logout_form,
1928 )
1929}
1930
1931#[allow(clippy::too_many_arguments)]
1934fn render_shell_page(
1935 shell: &Shell<'_>,
1936 status: u16,
1937 document_title: &str,
1938 page_title: &str,
1939 page_subtitle: Option<&str>,
1940 breadcrumbs: &[Crumb<'_>],
1941 actions: &str,
1942 body: &str,
1943) -> Response {
1944 let design = design::Design::global();
1945
1946 let sidebar = render_sidebar(shell);
1947 let crumbs = render_breadcrumbs(breadcrumbs);
1948
1949 let env_chip = env_chip_html();
1950
1951 let topbar_actions = match shell.csrf {
1957 Some(csrf) => format!(
1958 r#"<div class="rio-topbar-actions">
1959{env}
1960<a class="rio-topbar-icon" href="/admin" title="Home" aria-label="Home">{home}</a>
1961<button class="rio-topbar-icon" type="button" title="Notifications" aria-label="Notifications">{bell}<span class="rio-topbar-dot"></span></button>
1962<button class="rio-topbar-icon" type="button" title="Messages" aria-label="Messages">{mail}</button>
1963<form class="rio-topbar-logout" method="post" action="/admin/logout">
1964<input type="hidden" name="_csrf" value="{csrf_val}">
1965<button type="submit" title="Sign out">{logout}<span>Logout</span></button>
1966</form>
1967</div>"#,
1968 env = env_chip,
1969 home = icon_home(),
1970 bell = icon_bell(),
1971 mail = icon_mail(),
1972 logout = icon_logout(),
1973 csrf_val = escape_html(csrf),
1974 ),
1975 None => format!(
1976 r#"<div class="rio-topbar-actions">{env}</div>"#,
1977 env = env_chip
1978 ),
1979 };
1980
1981 let subtitle_html = page_subtitle
1982 .map(|s| format!(r#"<p class="rio-page-subtitle">{}</p>"#, escape_html(s)))
1983 .unwrap_or_default();
1984
1985 let actions_block = if actions.is_empty() {
1986 String::new()
1987 } else {
1988 format!(r#"<div class="rio-page-actions">{actions}</div>"#)
1989 };
1990
1991 let theme_style = format!(
1992 "\n:root {{\n --rio-primary: {p};\n --rio-primary-hover: {ph};\n --rio-accent: {a};\n --rio-accent-hover: {ah};\n}}\n",
1993 p = escape_css_color(&design.primary_color),
1994 ph = escape_css_color(&design.primary_color),
1995 a = escape_css_color(&design.accent_color),
1996 ah = escape_css_color(&design.accent_color),
1997 );
1998
1999 let density_class = match design.density {
2000 design::Density::Comfortable => "",
2001 design::Density::Compact => " rio-density-compact",
2002 };
2003
2004 let body_html = format!(
2005 r#"<!doctype html>
2006<html lang="en">
2007<head>
2008<meta charset="utf-8">
2009<meta name="viewport" content="width=device-width, initial-scale=1">
2010<title>{doc_title} · {project}</title>
2011<link rel="stylesheet" href="/admin/assets/admin.css?v={css_ver}">
2012<link rel="icon" type="image/svg+xml" href="/admin/assets/favicon.svg">
2013<style>{theme}</style>
2014</head>
2015<body class="rio-body{density}">
2016<div class="rio-app">
2017{sidebar}
2018<main class="rio-main">
2019<div class="rio-container">
2020<header class="rio-topbar">
2021{crumbs}
2022{topbar_actions}
2023</header>
2024<div class="rio-page-header">
2025<div>
2026<h1 class="rio-page-title">{page_title}</h1>
2027{subtitle}
2028</div>
2029{actions}
2030</div>
2031{body}
2032</div>
2033</main>
2034</div>
2035<script>
2036// Admin Intelligence Layer (0.7.0) — minimal JS for PII toggle.
2037// Click a .rio-pii-toggle to reveal / hide the adjacent masked value.
2038document.addEventListener("click", function(e){{
2039 var btn = e.target.closest ? e.target.closest(".rio-pii-toggle") : null;
2040 if(!btn) return;
2041 // The masked <span> is the button's previous sibling by construction.
2042 var span = btn.previousElementSibling;
2043 if(!span || !span.classList.contains("rio-pii")) return;
2044 if(span.getAttribute("data-hidden") === "1"){{
2045 span.textContent = span.getAttribute("data-value") || "";
2046 span.setAttribute("data-hidden","0");
2047 btn.textContent = "hide";
2048 }} else {{
2049 span.textContent = span.getAttribute("data-mask") || "";
2050 span.setAttribute("data-hidden","1");
2051 btn.textContent = "show";
2052 }}
2053}});
2054</script>
2055</body>
2056</html>"#,
2057 doc_title = escape_html(document_title),
2058 project = escape_html(&design.project_name),
2059 theme = theme_style,
2060 density = density_class,
2061 sidebar = sidebar,
2062 crumbs = crumbs,
2063 topbar_actions = topbar_actions,
2064 page_title = escape_html(page_title),
2065 subtitle = subtitle_html,
2066 actions = actions_block,
2067 body = body,
2068 css_ver = ADMIN_CSS_VER,
2069 );
2070
2071 let resp = hyper::Response::builder()
2072 .status(status)
2073 .header("content-type", "text/html; charset=utf-8")
2074 .header(
2079 "cache-control",
2080 "no-store, no-cache, must-revalidate, max-age=0",
2081 )
2082 .header("pragma", "no-cache")
2083 .header("expires", "0")
2084 .body(Full::new(Bytes::from(body_html)))
2085 .expect("valid response");
2086 with_admin_headers(resp)
2087}
2088
2089fn escape_css_color(s: &str) -> &str {
2095 if s.contains([';', '{', '}', '<', '\\']) {
2096 "#0f172a"
2098 } else {
2099 s
2100 }
2101}
2102
2103pub fn register_generated(
2112 registry: &mut crate::admin::admin_form_bridge::AdminRegistry,
2113 cfg: crate::admin::admin_generator::AdminModelConfig,
2114) {
2115 let slug = cfg.slug;
2116 registry.register(slug, move || {
2117 crate::admin::admin_generator::from_config(cfg.clone())
2118 });
2119}
2120
2121pub async fn register_from_table(
2128 db: &Db,
2129 registry: &mut crate::admin::admin_form_bridge::AdminRegistry,
2130 table: &str,
2131) -> Result<(), Error> {
2132 let cfg = crate::admin::schema_introspect::generate_from_table(db, table).await?;
2133 register_generated(registry, cfg);
2134 Ok(())
2135}
2136
2137fn empty_state_hint<T: AdminModel>(context: Option<&crate::ai::ContextConfig>) -> Option<String> {
2144 let ctx = context?;
2145 let schema = ctx.industry_schema()?;
2146 let model_has_convention = schema
2150 .required_fields
2151 .iter()
2152 .any(|f| T::FIELDS.iter().any(|af| af.name == f.as_str()));
2153 if !model_has_convention {
2154 return None;
2155 }
2156 let country_phrase = match ctx.country.as_deref() {
2157 Some(cc) if cc.eq_ignore_ascii_case("SE") => "In Sweden, ",
2158 Some(cc) if cc.eq_ignore_ascii_case("NO") => "In Norway, ",
2159 _ => "",
2160 };
2161 let industry = ctx.industry.as_deref().unwrap_or("");
2162 let singular_lower = T::singular_name().to_lowercase();
2163 let fields_list = schema.required_fields.join(", ");
2164 Some(format!(
2165 "{country}{industry} {singular}s usually include {fields}.",
2166 country = country_phrase,
2167 industry = industry,
2168 singular = singular_lower,
2169 fields = fields_list,
2170 ))
2171}
2172
2173struct ListFilters<'a> {
2181 q: Option<&'a str>,
2182 status: Option<&'a str>,
2183 status_options: &'a [String],
2184 priority: Option<&'a str>,
2185 priority_options: &'a [String],
2186 sort: Option<&'a str>,
2189 relation_filters: &'a [RelationFilterState],
2193 visible_columns: &'a [&'static str],
2197}
2198
2199struct RelationFilterState {
2202 field_name: String,
2204 label: String,
2206 current_value: Option<i64>,
2208 mode: RelationFilterMode,
2210}
2211
2212enum RelationFilterMode {
2213 Dropdown { options: Vec<(i64, String)> },
2217 Numeric { too_many: bool },
2221}
2222
2223impl ListFilters<'_> {
2224 fn is_active(&self) -> bool {
2225 self.q.is_some()
2226 || self.status.is_some()
2227 || self.priority.is_some()
2228 || self.sort.is_some()
2229 || self
2230 .relation_filters
2231 .iter()
2232 .any(|r| r.current_value.is_some())
2233 }
2234}
2235
2236const SORT_OPTIONS: &[(&str, &str)] = &[
2240 ("newest", "Newest first"),
2241 ("oldest", "Oldest first"),
2242 ("id_asc", "ID ↑"),
2243 ("id_desc", "ID ↓"),
2244];
2245
2246type FkLabels = std::collections::HashMap<String, std::collections::HashMap<i64, String>>;
2257
2258type FormRelationOptions = std::collections::HashMap<String, Vec<(i64, String)>>;
2271
2272struct CellCtx<'a> {
2276 registry: &'a relations::RelationRegistry,
2277 fk_labels: &'a FkLabels,
2278}
2279
2280impl CellCtx<'_> {
2281 fn empty() -> CellCtx<'static> {
2286 static EMPTY_REG: std::sync::OnceLock<relations::RelationRegistry> =
2287 std::sync::OnceLock::new();
2288 static EMPTY_LABELS: std::sync::OnceLock<FkLabels> = std::sync::OnceLock::new();
2289 CellCtx {
2290 registry: EMPTY_REG.get_or_init(relations::RelationRegistry::empty),
2291 fk_labels: EMPTY_LABELS.get_or_init(std::collections::HashMap::new),
2292 }
2293 }
2294}
2295
2296fn current_registry() -> relations::RelationRegistry {
2301 match schema_cache::snapshot() {
2302 Some(c) => relations::RelationRegistry::from_schema(&c.schema),
2303 None => relations::RelationRegistry::empty(),
2304 }
2305}
2306
2307type InverseCounts = std::collections::HashMap<String, i64>;
2328
2329async fn fetch_inverse_counts(
2339 db: &Db,
2340 target_model: &str,
2341 target_id: i64,
2342 registry: &relations::RelationRegistry,
2343) -> InverseCounts {
2344 use sqlx::Row;
2345 let mut out: InverseCounts = std::collections::HashMap::new();
2346 for inv in registry.has_many(target_model) {
2347 let sql = format!(
2348 "SELECT COUNT(*) AS rio_count FROM \"{table}\" WHERE \"{col}\" = ?",
2349 table = inv.source_table,
2350 col = inv.source_field,
2351 );
2352 let row = match sqlx::query(&sql).bind(target_id).fetch_one(db.pool()).await {
2353 Ok(r) => r,
2354 Err(_) => continue,
2355 };
2356 let count: i64 = row.try_get::<i64, _>("rio_count").unwrap_or_default();
2357 out.insert(format!("{}.{}", inv.source_model, inv.source_field), count);
2358 }
2359 out
2360}
2361
2362async fn build_relation_filters<T: AdminModel>(
2370 db: &Db,
2371 registry: &relations::RelationRegistry,
2372 query: &FormData,
2373) -> Vec<RelationFilterState> {
2374 use sqlx::Row;
2375 let mut out: Vec<RelationFilterState> = Vec::new();
2376 let cap = relations::RELATION_FILTER_DROPDOWN_CAP;
2377 for resolved in registry.belongs_to_of(T::singular_name()) {
2378 let current_value = query
2380 .get(&resolved.source_field)
2381 .and_then(|v| v.parse::<i64>().ok());
2382
2383 let mode = match &resolved.target_display_field {
2385 None => RelationFilterMode::Numeric { too_many: false },
2386 Some(display_col) => {
2387 let sql = format!(
2390 "SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" ORDER BY \"{col}\" ASC LIMIT {lim}",
2391 col = display_col,
2392 table = resolved.target_table,
2393 lim = cap + 1,
2394 );
2395 let rows = match sqlx::query(&sql).fetch_all(db.pool()).await {
2396 Ok(r) => r,
2397 Err(_) => {
2398 out.push(RelationFilterState {
2400 field_name: resolved.source_field.clone(),
2401 label: resolved.target_model.clone(),
2402 current_value,
2403 mode: RelationFilterMode::Numeric { too_many: false },
2404 });
2405 continue;
2406 }
2407 };
2408 if rows.len() > cap {
2409 RelationFilterMode::Numeric { too_many: true }
2410 } else {
2411 let options: Vec<(i64, String)> = rows
2412 .into_iter()
2413 .map(|row| {
2414 let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
2415 let label: String = row
2416 .try_get::<String, _>("rio_label")
2417 .or_else(|_| {
2418 row.try_get::<i64, _>("rio_label").map(|n| n.to_string())
2419 })
2420 .or_else(|_| {
2421 row.try_get::<i32, _>("rio_label").map(|n| n.to_string())
2422 })
2423 .unwrap_or_default();
2424 (id, label)
2425 })
2426 .collect();
2427 RelationFilterMode::Dropdown { options }
2428 }
2429 }
2430 };
2431
2432 out.push(RelationFilterState {
2433 field_name: resolved.source_field.clone(),
2434 label: resolved.target_model.clone(),
2435 current_value,
2436 mode,
2437 });
2438 }
2439 out
2440}
2441
2442async fn fetch_form_relation_options<T: AdminModel>(
2448 db: &Db,
2449 registry: &relations::RelationRegistry,
2450) -> FormRelationOptions {
2451 use sqlx::Row;
2452 let mut out: FormRelationOptions = std::collections::HashMap::new();
2453 let cap = relations::RELATION_FILTER_DROPDOWN_CAP;
2454 for resolved in registry.belongs_to_of(T::singular_name()) {
2455 let Some(display_col) = &resolved.target_display_field else {
2456 continue;
2457 };
2458 let sql = format!(
2459 "SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" ORDER BY \"{col}\" ASC LIMIT {lim}",
2460 col = display_col,
2461 table = resolved.target_table,
2462 lim = cap + 1,
2463 );
2464 let rows = match sqlx::query(&sql).fetch_all(db.pool()).await {
2465 Ok(r) => r,
2466 Err(_) => continue,
2467 };
2468 if rows.len() > cap {
2469 continue;
2472 }
2473 let options: Vec<(i64, String)> = rows
2474 .into_iter()
2475 .map(|row| {
2476 let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
2477 let label: String = row
2478 .try_get::<String, _>("rio_label")
2479 .or_else(|_| row.try_get::<i64, _>("rio_label").map(|n| n.to_string()))
2480 .or_else(|_| row.try_get::<i32, _>("rio_label").map(|n| n.to_string()))
2481 .unwrap_or_default();
2482 (id, label)
2483 })
2484 .collect();
2485 out.insert(resolved.source_field.clone(), options);
2486 }
2487 out
2488}
2489
2490async fn fetch_fk_labels<T: AdminModel>(
2491 db: &Db,
2492 items: &[&T],
2493 registry: &relations::RelationRegistry,
2494) -> FkLabels {
2495 use sqlx::Row;
2496 let mut out: FkLabels = std::collections::HashMap::new();
2497 let source_model = T::singular_name();
2498 for f in T::FIELDS {
2499 let Some(resolved) = registry.belongs_to(source_model, f.name) else {
2500 continue;
2501 };
2502 let Some(display_col) = &resolved.target_display_field else {
2503 continue;
2508 };
2509 let mut ids: Vec<i64> = items
2511 .iter()
2512 .filter_map(|it| it.field_display(f.name))
2513 .filter_map(|s| s.parse::<i64>().ok())
2514 .collect();
2515 ids.sort_unstable();
2516 ids.dedup();
2517 if ids.is_empty() {
2518 continue;
2519 }
2520 let placeholders: Vec<&'static str> = ids.iter().map(|_| "?").collect();
2523 let sql = format!(
2524 "SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" WHERE id IN ({ph})",
2525 col = display_col,
2526 table = resolved.target_table,
2527 ph = placeholders.join(","),
2528 );
2529 let mut q = sqlx::query(&sql);
2530 for id in &ids {
2531 q = q.bind(id);
2532 }
2533 let rows = match q.fetch_all(db.pool()).await {
2534 Ok(r) => r,
2535 Err(_) => continue,
2539 };
2540 let mut map: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
2541 for row in rows {
2542 let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
2543 let label: String = row
2544 .try_get::<String, _>("rio_label")
2545 .or_else(|_| row.try_get::<i64, _>("rio_label").map(|n| n.to_string()))
2546 .or_else(|_| row.try_get::<i32, _>("rio_label").map(|n| n.to_string()))
2547 .or_else(|_| row.try_get::<bool, _>("rio_label").map(|b| b.to_string()))
2548 .unwrap_or_default();
2549 map.insert(id, label);
2550 }
2551 out.insert(f.name.to_string(), map);
2552 }
2553 out
2554}
2555
2556fn list_response<T: AdminModel>(
2557 shell: Shell<'_>,
2558 items: &[&T],
2559 total: usize,
2560 filters: ListFilters<'_>,
2561 cell_ctx: &CellCtx<'_>,
2562) -> Response {
2563 let count = items.len();
2564 let singular = T::singular_name();
2565 let plural = T::DISPLAY_NAME;
2566 let admin_name = T::ADMIN_NAME;
2567
2568 let page_actions = format!(
2569 r#"<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{icon}<span>Add {singular}</span></a>"#,
2570 name = escape_html(admin_name),
2571 singular = escape_html(singular),
2572 icon = icon_plus(),
2573 );
2574
2575 let body = if total == 0 {
2580 let hint_html = match empty_state_hint::<T>(intelligence::context_global()) {
2581 Some(h) => format!(r#"<p class="rio-empty-hint">{}</p>"#, escape_html(&h)),
2582 None => String::new(),
2583 };
2584 format!(
2585 r#"<div class="rio-card">
2586<div class="rio-empty">
2587<div class="rio-empty-icon">{icon}</div>
2588<h3>Start by adding your first {singular_lower}</h3>
2589<p>This table is empty. Create the first record to get started.</p>
2590{hint}
2591<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{plus}<span>Add {singular_lower}</span></a>
2592</div>
2593</div>"#,
2594 icon = icon_inbox(),
2595 name = escape_html(admin_name),
2596 plus = icon_plus(),
2597 singular_lower = escape_html(&singular.to_lowercase()),
2598 hint = hint_html,
2599 )
2600 } else {
2601 let toolbar = render_list_toolbar::<T>(&filters, count, total);
2602 let chips = render_active_filter_chips(&filters, admin_name);
2607
2608 if items.is_empty() {
2609 format!(
2610 r#"<div class="rio-table-wrap">
2611{toolbar}
2612{chips}
2613<div class="rio-empty">
2614<div class="rio-empty-icon">{icon}</div>
2615<h3>No records match these filters</h3>
2616<p>Try a different search term, clear the filters, or add a new {singular_lower}.</p>
2617<div class="rio-empty-actions">
2618<a class="rio-btn" href="/admin/{name}">{reset}<span>Clear filters</span></a>
2619<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{plus}<span>Add {singular_lower}</span></a>
2620</div>
2621</div>
2622</div>"#,
2623 icon = icon_search(),
2624 singular_lower = escape_html(&singular.to_lowercase()),
2625 name = escape_html(admin_name),
2626 reset = icon_arrow_left(),
2627 plus = icon_plus(),
2628 )
2629 } else {
2630 let visible_fields: Vec<&AdminField> = T::FIELDS
2636 .iter()
2637 .filter(|f| filters.visible_columns.contains(&f.name))
2638 .collect();
2639 let has_hidden_fields = T::FIELDS.len() > visible_fields.len();
2644 let hidden_fields: Vec<&AdminField> = T::FIELDS
2645 .iter()
2646 .filter(|f| !filters.visible_columns.contains(&f.name))
2647 .collect();
2648 let colspan_total = visible_fields.len() + 3;
2654 let headers: String = visible_fields
2660 .iter()
2661 .map(|f| {
2662 format!(
2663 r#"<th data-col="{name}">{label}</th>"#,
2664 name = escape_html(f.name),
2665 label = escape_html(&humanise(f.name)),
2666 )
2667 })
2668 .collect();
2669 let expand_header = if has_hidden_fields {
2670 r#"<th class="rio-cell-expand" aria-label="Expand"></th>"#.to_string()
2671 } else {
2672 String::new()
2673 };
2674 let rows: String = items
2675 .iter()
2676 .map(|item| {
2677 let cells: String = visible_fields
2678 .iter()
2679 .map(|f| {
2680 let cell = render_cell::<T>(f, *item, cell_ctx);
2681 inject_data_col(&cell, f.name)
2682 })
2683 .collect();
2684 let id = item.id();
2685 let row_actions = format!(
2689 r#"<td class="rio-cell-actions">
2690<div class="rio-row-actions">
2691<a class="rio-btn rio-btn-sm" href="/admin/{name}/{id}/edit">{pencil}<span>Edit</span></a>
2692<a class="rio-btn rio-btn-sm rio-btn-danger-ghost" href="/admin/{name}/{id}/delete" rel="nofollow">{trash}<span>Delete</span></a>
2693</div>
2694</td>"#,
2695 name = escape_html(admin_name),
2696 id = id,
2697 pencil = icon_pencil(),
2698 trash = icon_trash(),
2699 );
2700 let checkbox = format!(
2701 r#"<td class="rio-cell-check"><input type="checkbox" class="rio-bulk-row" value="{id}" aria-label="Select row {id}"></td>"#,
2702 );
2703 if !has_hidden_fields {
2704 return format!("<tr>{checkbox}{cells}{row_actions}</tr>");
2705 }
2706 let expand_cell = format!(
2711 r#"<td class="rio-cell-expand"><button type="button" class="rio-expand-btn" data-expand-toggle aria-expanded="false" aria-label="Expand row {id}">▸</button></td>"#,
2712 );
2713 let detail_fields: String = hidden_fields
2714 .iter()
2715 .map(|f| {
2716 format!(
2717 r#"<div class="rio-expand-field"><dt>{label}</dt><dd>{value}</dd></div>"#,
2718 label = escape_html(&humanise(f.name)),
2719 value = render_cell_inner::<T>(f, *item, cell_ctx),
2720 )
2721 })
2722 .collect();
2723 let expand_row = format!(
2724 r#"<tr class="rio-row-expand" data-row-id="{id}" hidden><td colspan="{colspan}" class="rio-cell-expand-panel"><dl class="rio-expand-details">{fields}</dl></td></tr>"#,
2725 id = id,
2726 colspan = colspan_total,
2727 fields = detail_fields,
2728 );
2729 format!(
2730 r#"<tr class="rio-row-main" data-row-id="{id}">{expand_cell}{checkbox}{cells}{row_actions}</tr>{expand_row}"#,
2731 )
2732 })
2733 .collect();
2734
2735 let csrf = csrf_input(shell.csrf);
2736 let bulk_bar = format!(
2737 r#"<div class="rio-bulk-bar">
2738<label class="rio-bulk-label" for="rio-bulk-action">Action</label>
2739<select class="rio-select" id="rio-bulk-action" name="action">
2740<option value="">-- Select an action --</option>
2741<option value="delete">Delete selected {plural_lower}</option>
2742</select>
2743<button type="submit" class="rio-btn">Go</button>
2744<span class="rio-bulk-count" data-rio-bulk-count>0 selected</span>
2745</div>"#,
2746 plural_lower = escape_html(&plural.to_lowercase()),
2747 );
2748
2749 format!(
2750 r#"<div class="rio-table-wrap">
2751{toolbar}
2752{chips}
2753<form method="post" action="/admin/{name}/bulk_action" class="rio-bulk-form">
2754{csrf}
2755<input type="hidden" name="_selected" value="">
2756{bulk_bar}
2757<table class="rio-table">
2758<thead><tr>{expand_header}<th class="rio-cell-check"><input type="checkbox" class="rio-bulk-all" aria-label="Select all"></th>{headers}<th aria-label="Actions"></th></tr></thead>
2759<tbody>{rows}</tbody>
2760</table>
2761</form>
2762<script>
2763(function(){{
2764var form=document.querySelector('.rio-bulk-form');
2765if(form){{
2766 var all=form.querySelector('.rio-bulk-all');
2767 var rows=form.querySelectorAll('.rio-bulk-row');
2768 var count=form.querySelector('[data-rio-bulk-count]');
2769 var hidden=form.querySelector('input[name="_selected"]');
2770 function collect(){{var ids=[];rows.forEach(function(cb){{if(cb.checked)ids.push(cb.value);}});return ids;}}
2771 function update(){{var ids=collect();if(hidden)hidden.value=ids.join(',');if(count)count.textContent=ids.length+' selected';}}
2772 if(all)all.addEventListener('change',function(){{rows.forEach(function(cb){{cb.checked=all.checked;}});update();}});
2773 rows.forEach(function(cb){{cb.addEventListener('change',update);}});
2774 form.addEventListener('submit',function(e){{update();var ids=collect();var act=form.querySelector('[name="action"]');if(!ids.length||!act.value){{e.preventDefault();alert('Select one or more rows and an action, then click Go.');}}}});
2775 update();
2776}}
2777// Columns toggle (Change 2) — outside-click closes the <details>,
2778// and checkbox changes flip `display: none` on the matching <th>
2779// and every matching <td> via `data-col` attribute. Checkbox and
2780// actions columns carry no `data-col`, so they're never touched.
2781document.addEventListener('click',function(e){{
2782 var d=document.querySelector('details.rio-cols-ctl[open]');
2783 if(!d)return;
2784 if(d.contains(e.target))return;
2785 d.open=false;
2786}});
2787document.addEventListener('change',function(e){{
2788 var cb=e.target&&e.target.closest?e.target.closest('.rio-cols-check'):null;
2789 if(!cb)return;
2790 var col=cb.getAttribute('data-col');
2791 if(!col)return;
2792 var esc=(window.CSS&&CSS.escape)?CSS.escape(col):col;
2793 document.querySelectorAll('[data-col="'+esc+'"]').forEach(function(cell){{
2794 cell.style.display=cb.checked?'':'none';
2795 }});
2796}});
2797// More filters panel toggle (Change 3) — plain hidden-attribute
2798// flip. Button carries `data-more-filters-toggle` and an
2799// `aria-controls` pointing at the panel id. No outside-click
2800// handler, no animation.
2801document.addEventListener('click',function(e){{
2802 var btn=e.target&&e.target.closest?e.target.closest('[data-more-filters-toggle]'):null;
2803 if(!btn)return;
2804 var id=btn.getAttribute('aria-controls');
2805 var panel=id?document.getElementById(id):null;
2806 if(!panel)return;
2807 var open=!panel.hasAttribute('hidden');
2808 if(open){{
2809 panel.setAttribute('hidden','');
2810 btn.setAttribute('aria-expanded','false');
2811 }}else{{
2812 panel.removeAttribute('hidden');
2813 btn.setAttribute('aria-expanded','true');
2814 }}
2815}});
2816// Row expansion toggle (Change 5) — the button lives in the first
2817// column of each `.rio-row-main`, the paired `.rio-row-expand` is
2818// its `nextElementSibling`. Flip the `hidden` attribute + chevron
2819// glyph + aria-expanded; nothing else.
2820document.addEventListener('click',function(e){{
2821 var btn=e.target&&e.target.closest?e.target.closest('[data-expand-toggle]'):null;
2822 if(!btn)return;
2823 var main=btn.closest('tr');
2824 if(!main)return;
2825 var panel=main.nextElementSibling;
2826 if(!panel||!panel.classList.contains('rio-row-expand'))return;
2827 var open=!panel.hasAttribute('hidden');
2828 if(open){{
2829 panel.setAttribute('hidden','');
2830 btn.setAttribute('aria-expanded','false');
2831 btn.textContent='\u25B8';
2832 }}else{{
2833 panel.removeAttribute('hidden');
2834 btn.setAttribute('aria-expanded','true');
2835 btn.textContent='\u25BE';
2836 }}
2837}});
2838}})();
2839</script>
2840</div>"#,
2841 name = escape_html(admin_name),
2842 expand_header = expand_header,
2843 )
2844 }
2845 };
2846
2847 let crumbs: &[Crumb<'_>] = &[("Admin", Some("/admin")), (plural, None)];
2848
2849 render_shell_page(
2850 &shell,
2851 200,
2852 plural,
2853 plural,
2854 Some(&format!(
2855 "Browse, search, and manage {}.",
2856 plural.to_lowercase()
2857 )),
2858 crumbs,
2859 &page_actions,
2860 &body,
2861 )
2862}
2863
2864fn render_relation_filter_control(state: &RelationFilterState) -> String {
2871 let field = escape_html(&state.field_name);
2872 let label = escape_html(&state.label);
2873 match &state.mode {
2874 RelationFilterMode::Dropdown { options } => {
2875 let placeholder = format!("All {}", pluralise_label(&state.label));
2878 let options_html: String = std::iter::once(format!(
2879 r#"<option value="">{}</option>"#,
2880 escape_html(&placeholder),
2881 ))
2882 .chain(options.iter().map(|(id, display)| {
2883 let selected = state.current_value == Some(*id);
2884 let mark = if selected { " selected" } else { "" };
2885 format!(
2886 r#"<option value="{id}"{mark}>{display}</option>"#,
2887 id = id,
2888 mark = mark,
2889 display = escape_html(display),
2890 )
2891 }))
2892 .collect();
2893 format!(
2894 r#"<select class="rio-select" name="{field}" aria-label="Filter by {label}">{options_html}</select>"#,
2895 )
2896 }
2897 RelationFilterMode::Numeric { too_many } => {
2898 let current = state
2899 .current_value
2900 .map(|v| v.to_string())
2901 .unwrap_or_default();
2902 let hint = if *too_many {
2906 format!(
2907 r#"<span class="rio-field-hint">Too many options for a dropdown — enter the {label} ID directly.</span>"#,
2908 label = label,
2909 )
2910 } else {
2911 format!(
2912 r#"<span class="rio-field-hint">No display field declared for {label} — enter the ID directly.</span>"#,
2913 label = label,
2914 )
2915 };
2916 format!(
2917 r#"<label class="rio-field" style="display:inline-flex; gap:var(--rio-s-1); align-items:center; margin:0">\
2918<span class="rio-field-label">{label} ID</span>\
2919<input class="rio-input" type="number" name="{field}" value="{current}" style="width:140px" aria-label="Filter by {label} id">\
2920{hint}\
2921</label>"#,
2922 label = label,
2923 field = field,
2924 current = escape_html(¤t),
2925 hint = hint,
2926 )
2927 }
2928 }
2929}
2930
2931fn render_columns_control<T: AdminModel>(filters: &ListFilters<'_>) -> String {
2943 let rows: String = T::FIELDS
2944 .iter()
2945 .map(|f| {
2946 let is_visible = filters.visible_columns.contains(&f.name);
2947 let is_id = f.name == "id";
2948 let checked = if is_visible { " checked" } else { "" };
2949 let disabled = if is_id { " disabled" } else { "" };
2950 let mut tags: Vec<&'static str> = Vec::new();
2951 if is_visible {
2952 tags.push("primary");
2953 }
2954 if f.relation.is_some() {
2955 tags.push("relation");
2956 }
2957 let tag_html = if tags.is_empty() {
2958 String::new()
2959 } else {
2960 format!(" <small>{}</small>", tags.join(" · "))
2961 };
2962 format!(
2963 r#"<label class="rio-cols-panel-row"><input type="checkbox" class="rio-cols-check" data-col="{name}"{checked}{disabled}><span>{label}{tags}</span></label>"#,
2964 name = escape_html(f.name),
2965 label = escape_html(&humanise(f.name)),
2966 tags = tag_html,
2967 )
2968 })
2969 .collect();
2970
2971 format!(
2972 r#"<details class="rio-cols-ctl"><summary class="rio-btn">Columns</summary><div class="rio-cols-panel">{rows}</div></details>"#,
2973 )
2974}
2975
2976fn build_list_url(admin_name: &str, params: &[(String, String)]) -> String {
2982 if params.is_empty() {
2983 return format!("/admin/{}", admin_name);
2984 }
2985 let query: String = params
2986 .iter()
2987 .map(|(k, v)| format!("{}={}", k, v))
2988 .collect::<Vec<_>>()
2989 .join("&");
2990 format!("/admin/{}?{}", admin_name, query)
2991}
2992
2993fn current_filter_params(filters: &ListFilters<'_>) -> Vec<(String, String)> {
2999 let mut params: Vec<(String, String)> = Vec::new();
3000 if let Some(q) = filters.q.filter(|s| !s.is_empty()) {
3001 params.push(("q".to_string(), q.to_string()));
3002 }
3003 if let Some(s) = filters.status {
3004 params.push(("status".to_string(), s.to_string()));
3005 }
3006 if let Some(p) = filters.priority {
3007 params.push(("priority".to_string(), p.to_string()));
3008 }
3009 for r in filters.relation_filters {
3010 if let Some(id) = r.current_value {
3011 params.push((r.field_name.clone(), id.to_string()));
3012 }
3013 }
3014 if let Some(sort) = filters.sort {
3015 params.push(("sort".to_string(), sort.to_string()));
3016 }
3017 params
3018}
3019
3020fn render_active_filter_chips(filters: &ListFilters<'_>, admin_name: &str) -> String {
3028 let all_params = current_filter_params(filters);
3029 let remove_url = |exclude_key: &str| -> String {
3030 let kept: Vec<(String, String)> = all_params
3031 .iter()
3032 .filter(|(k, _)| k != exclude_key)
3033 .cloned()
3034 .collect();
3035 build_list_url(admin_name, &kept)
3036 };
3037
3038 let mut chips: Vec<String> = Vec::new();
3039
3040 if let Some(q) = filters.q.filter(|s| !s.is_empty()) {
3041 chips.push(format!(
3042 r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">Search:</span> <span class="admin-filter-chip-value">"{value}"</span> <a href="{href}" aria-label="Remove search filter">×</a></span>"#,
3043 value = escape_html(q),
3044 href = escape_html(&remove_url("q")),
3045 ));
3046 }
3047 if let Some(s) = filters.status {
3048 chips.push(format!(
3049 r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">Status:</span> <span class="admin-filter-chip-value">{value}</span> <a href="{href}" aria-label="Remove status filter">×</a></span>"#,
3050 value = escape_html(&humanise_enum_value(s)),
3051 href = escape_html(&remove_url("status")),
3052 ));
3053 }
3054 if let Some(p) = filters.priority {
3055 chips.push(format!(
3056 r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">Priority:</span> <span class="admin-filter-chip-value">{value}</span> <a href="{href}" aria-label="Remove priority filter">×</a></span>"#,
3057 value = escape_html(p),
3058 href = escape_html(&remove_url("priority")),
3059 ));
3060 }
3061 for r in filters.relation_filters {
3062 if let Some(id) = r.current_value {
3063 let display = match &r.mode {
3064 RelationFilterMode::Dropdown { options } => options
3065 .iter()
3066 .find(|(opt_id, _)| *opt_id == id)
3067 .map(|(_, name)| name.clone())
3068 .unwrap_or_else(|| format!("#{}", id)),
3069 RelationFilterMode::Numeric { .. } => format!("#{}", id),
3070 };
3071 chips.push(format!(
3072 r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">{label}:</span> <span class="admin-filter-chip-value">{value}</span> <a href="{href}" aria-label="Remove {label} filter">×</a></span>"#,
3073 label = escape_html(&r.label),
3074 value = escape_html(&display),
3075 href = escape_html(&remove_url(&r.field_name)),
3076 ));
3077 }
3078 }
3079
3080 if chips.is_empty() {
3081 return String::new();
3082 }
3083
3084 format!(
3085 r#"<div class="admin-filter-chips">{chips}<a class="admin-filter-clear-all" href="/admin/{name}">Clear all</a></div>"#,
3086 chips = chips.join(""),
3087 name = escape_html(admin_name),
3088 )
3089}
3090
3091fn render_more_filters_panel(
3098 status_select: &str,
3099 priority_select: &str,
3100 secondary_relations_html: &str,
3101) -> String {
3102 if status_select.is_empty() && priority_select.is_empty() && secondary_relations_html.is_empty()
3103 {
3104 return String::new();
3105 }
3106 format!(
3107 r#"<div class="admin-list-more-filters" id="more-filters-panel" hidden><div class="admin-filter-grid">{status}{priority}{relations}</div></div>"#,
3108 status = status_select,
3109 priority = priority_select,
3110 relations = secondary_relations_html,
3111 )
3112}
3113
3114fn render_list_toolbar<T: AdminModel>(
3115 filters: &ListFilters<'_>,
3116 shown: usize,
3117 total: usize,
3118) -> String {
3119 let admin_name = T::ADMIN_NAME;
3120 let plural = T::DISPLAY_NAME;
3121
3122 let q_value = filters.q.map(escape_html).unwrap_or_default();
3123
3124 let status_select = if !filters.status_options.is_empty() {
3130 let options: String =
3131 std::iter::once(r#"<option value="">All statuses</option>"#.to_string())
3132 .chain(filters.status_options.iter().map(|v| {
3133 let selected = if filters.status.map(|s| s == v).unwrap_or(false) {
3134 " selected"
3135 } else {
3136 ""
3137 };
3138 format!(
3139 r#"<option value="{v}"{selected}>{label}</option>"#,
3140 v = escape_html(v),
3141 label = escape_html(&humanise_enum_value(v)),
3142 )
3143 }))
3144 .collect();
3145 format!(
3146 r#"<select class="rio-select" name="status" aria-label="Filter by status">{options}</select>"#,
3147 )
3148 } else {
3149 String::new()
3150 };
3151
3152 let priority_select = if !filters.priority_options.is_empty() {
3156 let mut sorted_priorities: Vec<&String> = filters.priority_options.iter().collect();
3157 sorted_priorities.sort_by(|a, b| {
3158 let na: Option<i64> = a.parse().ok();
3159 let nb: Option<i64> = b.parse().ok();
3160 match (na, nb) {
3161 (Some(x), Some(y)) => x.cmp(&y),
3162 _ => a.cmp(b),
3163 }
3164 });
3165 let options: String =
3166 std::iter::once(r#"<option value="">All priorities</option>"#.to_string())
3167 .chain(sorted_priorities.iter().map(|v| {
3168 let selected = if filters.priority.map(|p| p == v.as_str()).unwrap_or(false) {
3169 " selected"
3170 } else {
3171 ""
3172 };
3173 format!(
3174 r#"<option value="{v}"{selected}>Priority {v}</option>"#,
3175 v = escape_html(v),
3176 )
3177 }))
3178 .collect();
3179 format!(
3180 r#"<select class="rio-select" name="priority" aria-label="Filter by priority">{options}</select>"#,
3181 )
3182 } else {
3183 String::new()
3184 };
3185
3186 let (primary_relation_html, secondary_relations_html): (String, String) = {
3190 let mut iter = filters.relation_filters.iter();
3191 let first = iter
3192 .next()
3193 .map(render_relation_filter_control)
3194 .unwrap_or_default();
3195 let rest: String = iter.map(render_relation_filter_control).collect();
3196 (first, rest)
3197 };
3198
3199 let secondary_active_count: usize = filters.status.is_some() as usize
3203 + filters.priority.is_some() as usize
3204 + filters
3205 .relation_filters
3206 .iter()
3207 .skip(1)
3208 .filter(|r| r.current_value.is_some())
3209 .count();
3210
3211 let more_filters_panel_html =
3216 render_more_filters_panel(&status_select, &priority_select, &secondary_relations_html);
3217 let more_filters_btn = if more_filters_panel_html.is_empty() {
3218 String::new()
3219 } else {
3220 let label = if secondary_active_count == 0 {
3221 "More filters".to_string()
3222 } else {
3223 format!("More filters ({secondary_active_count})")
3224 };
3225 format!(
3226 r#"<button type="button" class="rio-btn" data-more-filters-toggle aria-controls="more-filters-panel" aria-expanded="false">{label}</button>"#,
3227 )
3228 };
3229
3230 let reset_btn = if filters.is_active() {
3231 format!(
3232 r#"<a class="rio-btn rio-btn-ghost" href="/admin/{name}">Reset</a>"#,
3233 name = escape_html(admin_name),
3234 )
3235 } else {
3236 String::new()
3237 };
3238
3239 let sort_select = {
3244 let current = filters.sort.unwrap_or("newest");
3245 let options: String = SORT_OPTIONS
3246 .iter()
3247 .map(|(value, label)| {
3248 let sel = if *value == current { " selected" } else { "" };
3249 format!(
3250 r#"<option value="{v}"{sel}>{l}</option>"#,
3251 v = escape_html(value),
3252 l = escape_html(label),
3253 )
3254 })
3255 .collect();
3256 format!(
3257 r#"<select class="rio-select rio-select-sort" name="sort" aria-label="Sort records">{options}</select>"#,
3258 )
3259 };
3260
3261 let count_label = if filters.is_active() {
3262 format!("Showing {shown} of {total}")
3263 } else if total == 1 {
3264 "1 record".to_string()
3265 } else {
3266 format!("{total} records")
3267 };
3268
3269 let intent_badge = filters
3274 .q
3275 .filter(|q| !q.is_empty())
3276 .map(|q| match intelligence::classify_search(q) {
3277 intelligence::SearchIntent::Text(_) => String::new(),
3278 other => format!(
3279 r#"<span class="rio-search-intent">Interpreted as: {}</span>"#,
3280 escape_html(other.label()),
3281 ),
3282 })
3283 .unwrap_or_default();
3284
3285 let columns_control = render_columns_control::<T>(filters);
3291
3292 format!(
3293 r#"<form class="rio-table-toolbar" method="get" action="/admin/{name}" role="search" aria-label="Search {plural}">
3294<div class="rio-search">
3295{search_icon}
3296<input type="search" name="q" value="{q}" placeholder="Search {plural_lower}…" aria-label="Search text">
3297{intent}
3298</div>
3299{primary_relation}
3300{sort}
3301<div class="rio-toolbar-actions">
3302<button type="submit" class="rio-btn rio-btn-primary">{submit_icon}<span>Search</span></button>
3303{more_filters_btn}
3304{reset}
3305{columns}
3306</div>
3307<div class="rio-count">{count}</div>
3308{more_filters_panel}
3309</form>"#,
3310 name = escape_html(admin_name),
3311 plural = escape_html(plural),
3312 plural_lower = escape_html(&plural.to_lowercase()),
3313 search_icon = icon_search(),
3314 q = q_value,
3315 intent = intent_badge,
3316 primary_relation = primary_relation_html,
3317 sort = sort_select,
3318 submit_icon = icon_search(),
3319 more_filters_btn = more_filters_btn,
3320 reset = reset_btn,
3321 columns = columns_control,
3322 count = escape_html(&count_label),
3323 more_filters_panel = more_filters_panel_html,
3324 )
3325}
3326
3327fn matches_query<T: AdminModel>(item: &T, needle: &str) -> bool {
3332 let needle = needle.to_lowercase();
3333 if item.id().to_string().contains(&needle) {
3334 return true;
3335 }
3336 for f in T::FIELDS.iter() {
3337 if matches!(f.ty, FieldType::String) {
3338 if let Some(v) = item.field_display(f.name) {
3339 if v.to_lowercase().contains(&needle) {
3340 return true;
3341 }
3342 }
3343 }
3344 }
3345 false
3346}
3347
3348fn distinct_values<T: AdminModel>(items: &[T], field_name: &str) -> Vec<String> {
3352 if !T::FIELDS.iter().any(|f| f.name == field_name) {
3353 return Vec::new();
3354 }
3355 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3356 for item in items {
3357 if let Some(v) = item.field_display(field_name) {
3358 if !v.is_empty() {
3359 set.insert(v);
3360 }
3361 }
3362 }
3363 set.into_iter().collect()
3364}
3365
3366fn status_pill_class(value: &str) -> &'static str {
3370 match value {
3371 "done" | "complete" | "completed" | "finished" | "resolved" => "rio-pill rio-pill-emerald",
3372 "active" | "approved" | "published" | "live" => "rio-pill rio-pill-emerald",
3373 "pending" | "todo" | "queued" | "open" | "new" => "rio-pill rio-pill-amber",
3374 "in_progress" | "doing" | "working" | "review" | "in_review" => "rio-pill rio-pill-indigo",
3375 "archived" | "inactive" | "closed" | "cancelled" | "canceled" => "rio-pill rio-pill-slate",
3376 "blocked" | "failed" | "rejected" | "error" => "rio-pill rio-pill-rose",
3377 _ => "rio-pill rio-pill-slate",
3378 }
3379}
3380
3381fn inject_data_col(cell: &str, col: &str) -> String {
3399 let trimmed_offset = cell.len() - cell.trim_start().len();
3400 let rest = &cell[trimmed_offset..];
3401 if !rest.starts_with("<td") {
3402 return cell.to_string();
3403 }
3404 let (leading_ws, after_ws) = cell.split_at(trimmed_offset);
3405 let after_td = &after_ws[3..];
3407 format!(
3408 r#"{leading_ws}<td data-col="{col}"{after_td}"#,
3409 col = escape_html(col),
3410 )
3411}
3412
3413fn render_cell<T: AdminModel>(f: &AdminField, item: &T, ctx: &CellCtx<'_>) -> String {
3414 let value = item.field_display(f.name).unwrap_or_default();
3415 if f.name == "id" {
3416 return format!(r#"<td class="rio-cell-id">#{}</td>"#, escape_html(&value));
3417 }
3418 if value.is_empty() && f.nullable {
3419 return r#"<td class="rio-cell-muted">—</td>"#.to_string();
3420 }
3421 if let Some(resolved) = ctx.registry.belongs_to(T::singular_name(), f.name) {
3425 if let Ok(id) = value.parse::<i64>() {
3426 let label = ctx.fk_labels.get(f.name).and_then(|m| m.get(&id));
3427 let admin = escape_html(&resolved.target_admin_name);
3428 return match (label, &resolved.target_display_field) {
3429 (Some(name), _) => format!(
3431 r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">{name}</a> <span class="rio-cell-id">#{id}</span></td>"#,
3432 admin = admin,
3433 id = id,
3434 name = escape_html(name),
3435 ),
3436 (None, Some(_)) => format!(
3440 r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">#{id}</a></td>"#,
3441 admin = admin,
3442 id = id,
3443 ),
3444 (None, None) => format!(
3447 r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">#{id}</a></td>"#,
3448 admin = admin,
3449 id = id,
3450 ),
3451 };
3452 }
3453 }
3456 let ctx = intelligence::context_global();
3460 let ui = intelligence::field_ui_metadata(f, ctx);
3461 if ui.sensitive && !value.is_empty() {
3462 let masked = intelligence::mask_pii(&value);
3463 return format!(
3464 r#"<td class="rio-cell-muted">\
3465<span class="rio-pii" data-value="{real}" data-mask="{mask}" data-hidden="1">{mask}</span>\
3466<button class="rio-pii-toggle" type="button" aria-label="Reveal value">show</button>\
3467</td>"#,
3468 real = escape_html(&value),
3469 mask = escape_html(&masked),
3470 );
3471 }
3472 if matches!(f.ty, FieldType::Bool) {
3473 let (cls, label) = match value.as_str() {
3474 "true" => ("rio-pill rio-pill-emerald", "active"),
3475 "false" => ("rio-pill rio-pill-slate", "inactive"),
3476 other => ("rio-pill rio-pill-slate", other),
3477 };
3478 return format!(
3479 r#"<td><span class="{cls}">{}</span></td>"#,
3480 escape_html(label)
3481 );
3482 }
3483 if f.name == "status" && matches!(f.ty, FieldType::String) {
3486 let cls = status_pill_class(&value);
3487 let label = value.replace('_', " ");
3488 return format!(
3489 r#"<td><span class="{cls}">{}</span></td>"#,
3490 escape_html(&label)
3491 );
3492 }
3493 if matches!(f.ty, FieldType::I32 | FieldType::I64) {
3495 return format!(r#"<td class="rio-cell-num">{}</td>"#, escape_html(&value));
3496 }
3497 let is_primary = f.name != "id"
3499 && T::FIELDS
3500 .iter()
3501 .find(|x| x.name != "id")
3502 .map(|first| first.name == f.name)
3503 .unwrap_or(false);
3504 let cls = if is_primary {
3505 "rio-cell-primary"
3506 } else {
3507 "rio-cell-muted"
3508 };
3509 format!(r#"<td class="{cls}">{}</td>"#, escape_html(&value))
3510}
3511
3512fn render_cell_inner<T: AdminModel>(f: &AdminField, item: &T, ctx: &CellCtx<'_>) -> String {
3518 let cell = render_cell::<T>(f, item, ctx);
3519 let start = cell.find('>').map(|i| i + 1).unwrap_or(0);
3520 let end = cell.rfind("</td>").unwrap_or(cell.len());
3521 cell[start..end].to_string()
3522}
3523
3524enum FormMode<'a, T: AdminModel> {
3529 Create,
3530 Edit { id: i64, item: &'a T },
3531}
3532
3533fn form_response<T: AdminModel>(
3534 shell: Shell<'_>,
3535 mode: FormMode<'_, T>,
3536 cell_ctx: &CellCtx<'_>,
3537 inverse_counts: &InverseCounts,
3538 form_options: &FormRelationOptions,
3539) -> Response {
3540 let plural = T::DISPLAY_NAME;
3541 let singular = T::singular_name();
3542 let admin_name = T::ADMIN_NAME;
3543
3544 let (heading, doc_title, subtitle, action, back_label) = match &mode {
3545 FormMode::Create => (
3546 format!("New {singular}"),
3547 format!("New {singular}"),
3548 format!("Create a new {} record.", singular.to_lowercase()),
3549 format!("/admin/{admin_name}/create"),
3550 format!("Back to {}", plural.to_lowercase()),
3551 ),
3552 FormMode::Edit { id, .. } => (
3553 format!("Edit {singular}"),
3554 format!("Edit {singular} #{id}"),
3555 format!("Update this {} record.", singular.to_lowercase()),
3556 format!("/admin/{admin_name}/{id}/edit"),
3557 format!("Back to {}", plural.to_lowercase()),
3558 ),
3559 };
3560
3561 let fields: String = T::FIELDS
3562 .iter()
3563 .filter(|f| f.editable)
3564 .map(|f| {
3565 render_field_block::<T>(
3566 f,
3567 match &mode {
3568 FormMode::Create => None,
3569 FormMode::Edit { item, .. } => Some(*item),
3570 },
3571 cell_ctx,
3572 form_options,
3573 )
3574 })
3575 .collect();
3576
3577 let meta_block = match &mode {
3578 FormMode::Create => String::new(),
3579 FormMode::Edit { id, item } => render_meta::<T>(*id, item),
3580 };
3581
3582 let inverse_panel = match &mode {
3588 FormMode::Create => String::new(),
3589 FormMode::Edit { id, .. } => {
3590 render_inverse_panel::<T>(cell_ctx.registry, inverse_counts, *id)
3591 }
3592 };
3593
3594 let danger_zone = match &mode {
3595 FormMode::Create => String::new(),
3596 FormMode::Edit { id, .. } => format!(
3597 r#"<section class="rio-danger-zone">
3598<div class="rio-danger-copy">
3599<h3 class="rio-danger-title">{warn}<span>Delete this {singular}</span></h3>
3600<p class="rio-danger-hint">Permanently removes this record. Rows that reference it with <code>ON DELETE CASCADE</code> will also be deleted.</p>
3601</div>
3602<a class="rio-btn rio-btn-danger" href="/admin/{name}/{id}/delete" rel="nofollow">{trash}<span>Delete record</span></a>
3603</section>"#,
3604 warn = icon_triangle_alert(),
3605 singular = escape_html(&singular.to_lowercase()),
3606 name = escape_html(admin_name),
3607 id = id,
3608 trash = icon_trash(),
3609 ),
3610 };
3611
3612 let csrf_hidden = csrf_input(shell.csrf);
3613
3614 let body = format!(
3615 r#"{meta}
3616<form class="rio-card rio-form" method="post" action="{action}" autocomplete="off">
3617{csrf}
3618<div class="rio-form-section">
3619<h2 class="rio-form-section-title">Details</h2>
3620<p class="rio-form-section-hint">Fields marked optional accept an empty value.</p>
3621{fields}
3622</div>
3623<div class="rio-form-footer">
3624<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back_icon}<span>{back_label}</span></a>
3625<div class="rio-footer-actions">
3626<a class="rio-btn" href="/admin/{name}">Cancel</a>
3627<button class="rio-btn rio-btn-primary" type="submit">Save</button>
3628</div>
3629</div>
3630</form>
3631{inverse}
3632{danger}"#,
3633 meta = meta_block,
3634 action = escape_html(&action),
3635 csrf = csrf_hidden,
3636 fields = fields,
3637 name = escape_html(admin_name),
3638 back_icon = icon_arrow_left(),
3639 back_label = escape_html(&back_label),
3640 inverse = inverse_panel,
3641 danger = danger_zone,
3642 );
3643
3644 let plural_href = format!("/admin/{admin_name}");
3648 let crumbs: Vec<Crumb<'_>> = match &mode {
3649 FormMode::Create => vec![
3650 ("Admin", Some("/admin")),
3651 (plural, Some(plural_href.as_str())),
3652 ("New", None),
3653 ],
3654 FormMode::Edit { .. } => vec![
3655 ("Admin", Some("/admin")),
3656 (plural, Some(plural_href.as_str())),
3657 ("Edit", None),
3658 ],
3659 };
3660
3661 let page_actions = match &mode {
3665 FormMode::Create => String::new(),
3666 FormMode::Edit { id, .. } => format!(
3667 r#"<a class="rio-btn" href="/admin/{name}/{id}/history">History</a>"#,
3668 name = escape_html(admin_name),
3669 id = id,
3670 ),
3671 };
3672
3673 render_shell_page(
3674 &shell,
3675 200,
3676 &doc_title,
3677 &heading,
3678 Some(&subtitle),
3679 &crumbs,
3680 &page_actions,
3681 &body,
3682 )
3683}
3684
3685fn render_field_block<T: AdminModel>(
3693 f: &AdminField,
3694 item: Option<&T>,
3695 cell_ctx: &CellCtx<'_>,
3696 form_options: &FormRelationOptions,
3697) -> String {
3698 let name = escape_html(f.name);
3699 let mut ui = intelligence::field_ui_metadata(f, intelligence::context_global());
3700 if f.relation.is_some() {
3706 ui.hint = None;
3707 ui.label = field_label(f);
3708 }
3709 let input = render_field::<T>(f, item, ui.placeholder.as_deref(), form_options);
3710
3711 if matches!(f.ty, FieldType::Bool) {
3713 return format!(
3714 r#"<div class="rio-field rio-field-row-checkbox">
3715{input}
3716<label for="_{name}">{label}</label>
3717</div>"#,
3718 input = input,
3719 name = name,
3720 label = escape_html(&ui.label),
3721 );
3722 }
3723
3724 let optional_mark = if f.nullable {
3725 r#"<span class="rio-field-optional">optional</span>"#.to_string()
3726 } else {
3727 String::new()
3728 };
3729 let sensitive_mark = if ui.sensitive {
3730 let note = ui
3731 .sensitivity_note
3732 .as_deref()
3733 .unwrap_or("Personal data — handle with care.");
3734 format!(
3735 r#"<span class="rio-field-sensitive" title="{note}">🔒 PII</span>"#,
3736 note = escape_html(note),
3737 )
3738 } else {
3739 String::new()
3740 };
3741 let hint_html = match ui.hint.as_deref() {
3742 Some(h) => format!(r#"<p class="rio-field-hint">{}</p>"#, escape_html(h),),
3743 None => String::new(),
3744 };
3745
3746 let relation_hint = render_relation_hint::<T>(f, item, cell_ctx);
3752
3753 format!(
3754 r#"<div class="rio-field">
3755<label for="_{name}">{label}{optional}{sensitive}</label>
3756{input}
3757{rel}
3758{hint}
3759</div>"#,
3760 name = name,
3761 label = escape_html(&ui.label),
3762 optional = optional_mark,
3763 sensitive = sensitive_mark,
3764 rel = relation_hint,
3765 hint = hint_html,
3766 )
3767}
3768
3769fn render_relation_hint<T: AdminModel>(
3776 f: &AdminField,
3777 item: Option<&T>,
3778 cell_ctx: &CellCtx<'_>,
3779) -> String {
3780 let Some(item) = item else {
3781 return String::new();
3782 };
3783 if f.relation.is_none() {
3784 return String::new();
3785 }
3786 let Some(resolved) = cell_ctx.registry.belongs_to(T::singular_name(), f.name) else {
3787 return String::new();
3788 };
3789 let Some(value) = item.field_display(f.name) else {
3790 return String::new();
3791 };
3792 let Ok(id) = value.parse::<i64>() else {
3793 return String::new();
3794 };
3795 let label = cell_ctx.fk_labels.get(f.name).and_then(|m| m.get(&id));
3796 let admin = escape_html(&resolved.target_admin_name);
3797 match (label, &resolved.target_display_field) {
3798 (Some(name), _) => format!(
3799 r#"<p class="rio-field-hint">Linked: <a href="/admin/{admin}/{id}">{name}</a> <span class="rio-cell-id">#{id}</span></p>"#,
3800 admin = admin,
3801 id = id,
3802 name = escape_html(name),
3803 ),
3804 (None, _) => format!(
3805 r#"<p class="rio-field-hint">Linked: <a href="/admin/{admin}/{id}">#{id}</a></p>"#,
3806 admin = admin,
3807 id = id,
3808 ),
3809 }
3810}
3811
3812fn is_foreign_key_violation(e: &Error) -> bool {
3818 matches!(e, Error::Internal(msg) if msg.contains("FOREIGN KEY constraint failed"))
3819}
3820
3821fn render_delete_blocked_page<T: AdminModel>(
3826 shell: &Shell<'_>,
3827 target_id: i64,
3828 target_primary: &str,
3829 blockers: &[(&relations::InverseRelation, i64)],
3830) -> Response {
3831 let singular = T::singular_name();
3832 let admin_name = T::ADMIN_NAME;
3833 let plural = T::DISPLAY_NAME;
3834 let subject = if target_primary.is_empty() {
3835 format!("{singular} #{target_id}")
3836 } else {
3837 format!("{target_primary} (#{target_id})")
3838 };
3839
3840 let rows: String = blockers
3841 .iter()
3842 .map(|(inv, count)| {
3843 let filter_url = format!(
3844 "/admin/{}?{}={}",
3845 inv.source_admin_name,
3846 urlencoding_light(&inv.source_field),
3847 target_id,
3848 );
3849 format!(
3850 r#"<li class="rio-dashboard-alert"><div><strong>{label}</strong> — referenced by <strong>{count}</strong> row{plural_s} via <code>{field}</code></div><a class="rio-btn rio-btn-sm" href="{url}">Open {label_lower}</a></li>"#,
3851 label = escape_html(&inv.source_display_name),
3852 label_lower = escape_html(&inv.source_display_name.to_lowercase()),
3853 field = escape_html(&inv.source_field),
3854 count = count,
3855 plural_s = if *count == 1 { "" } else { "s" },
3856 url = escape_html(&filter_url),
3857 )
3858 })
3859 .collect();
3860
3861 let back_href = format!("/admin/{}", admin_name);
3862 let body = format!(
3863 r#"<section class="rio-card">
3864<div class="rio-card-header">
3865<h2 class="rio-card-title">Cannot delete {subject}</h2>
3866<p class="rio-card-subtitle">Other records reference this one. Remove or reassign them first, then retry the delete.</p>
3867</div>
3868<ul class="rio-dashboard-alerts" style="list-style:none; margin:0; padding:var(--rio-card-pad)">
3869{rows}
3870</ul>
3871<div class="rio-form-footer">
3872<a class="rio-btn" href="{back}">Back to {plural_lower}</a>
3873</div>
3874</section>"#,
3875 subject = escape_html(&subject),
3876 rows = rows,
3877 back = escape_html(&back_href),
3878 plural_lower = escape_html(&plural.to_lowercase()),
3879 );
3880
3881 let plural_href = back_href.clone();
3882 let crumbs: Vec<Crumb<'_>> = vec![
3883 ("Admin", Some("/admin")),
3884 (plural, Some(plural_href.as_str())),
3885 ("Delete blocked", None),
3886 ];
3887 let doc_title = format!("Cannot delete {subject}");
3888 render_shell_page(
3889 shell,
3890 409,
3891 &doc_title,
3892 "Delete blocked",
3893 Some("Remove the dependent references first, then retry."),
3894 &crumbs,
3895 "",
3896 &body,
3897 )
3898}
3899
3900fn render_inverse_panel<T: AdminModel>(
3910 registry: &relations::RelationRegistry,
3911 counts: &InverseCounts,
3912 target_id: i64,
3913) -> String {
3914 let inverses = registry.has_many(T::singular_name());
3915 if inverses.is_empty() {
3916 return String::new();
3917 }
3918 let cards: String = inverses
3919 .iter()
3920 .map(|inv| {
3921 let key = format!("{}.{}", inv.source_model, inv.source_field);
3922 let count = counts.get(&key).copied().unwrap_or(0);
3923 let label = inv.source_display_name.to_string();
3924 let filter_url = format!(
3928 "/admin/{}?{}={}",
3929 inv.source_admin_name,
3930 urlencoding_light(&inv.source_field),
3931 target_id,
3932 );
3933 format!(
3934 r#"<li><a href="{url}" class="rio-suggestion-card"><div><strong>{label}</strong> <span class="rio-cell-id">({count})</span></div><div class="rio-cell-muted">via {field}</div></a></li>"#,
3935 url = escape_html(&filter_url),
3936 label = escape_html(&label),
3937 count = count,
3938 field = escape_html(&inv.source_field),
3939 )
3940 })
3941 .collect();
3942 format!(
3943 r#"<section class="rio-card rio-related">
3944<div class="rio-card-header">
3945<h2 class="rio-card-title">Related</h2>
3946<p class="rio-card-subtitle">Incoming references to this record.</p>
3947</div>
3948<ul class="rio-related-grid">
3949{cards}
3950</ul>
3951</section>"#,
3952 cards = cards,
3953 )
3954}
3955
3956fn urlencoding_light(s: &str) -> String {
3962 let mut out = String::with_capacity(s.len());
3963 for ch in s.chars() {
3964 match ch {
3965 ' ' => out.push_str("%20"),
3966 '&' => out.push_str("%26"),
3967 '=' => out.push_str("%3D"),
3968 '#' => out.push_str("%23"),
3969 '?' => out.push_str("%3F"),
3970 _ => out.push(ch),
3971 }
3972 }
3973 out
3974}
3975
3976fn humanise(s: &str) -> String {
3978 let mut out = String::with_capacity(s.len());
3979 let mut next_upper = true;
3980 for ch in s.chars() {
3981 if ch == '_' {
3982 out.push(' ');
3983 next_upper = true;
3984 } else if next_upper {
3985 out.push(ch.to_ascii_uppercase());
3986 next_upper = false;
3987 } else {
3988 out.push(ch);
3989 }
3990 }
3991 out
3992}
3993
3994fn humanise_enum_value(s: &str) -> String {
3999 let mut out = String::with_capacity(s.len());
4000 let mut first = true;
4001 for ch in s.chars() {
4002 if ch == '_' {
4003 out.push(' ');
4004 } else if first {
4005 out.push(ch.to_ascii_uppercase());
4006 first = false;
4007 } else {
4008 out.push(ch.to_ascii_lowercase());
4009 }
4010 }
4011 out
4012}
4013
4014fn pluralise_label(s: &str) -> String {
4019 let lower = s.to_lowercase();
4020 if lower.ends_with('s')
4021 || lower.ends_with('x')
4022 || lower.ends_with("ch")
4023 || lower.ends_with("sh")
4024 {
4025 format!("{lower}es")
4026 } else if lower.ends_with('y')
4027 && !lower.ends_with("ay")
4028 && !lower.ends_with("ey")
4029 && !lower.ends_with("iy")
4030 && !lower.ends_with("oy")
4031 && !lower.ends_with("uy")
4032 {
4033 format!("{}ies", &lower[..lower.len() - 1])
4034 } else {
4035 format!("{lower}s")
4036 }
4037}
4038
4039const DEFAULT_VISIBLE_COLUMNS: usize = 5;
4043
4044const NAME_LIKE_FIELDS: &[&str] = &["name", "full_name", "title", "email"];
4050
4051pub(crate) fn is_primary_column(f: &AdminField) -> bool {
4071 if f.name == "id" {
4073 return true;
4074 }
4075 if f.relation.is_some() && f.name.ends_with("_id") {
4078 return true;
4079 }
4080 if matches!(f.ty, FieldType::Bool) && f.name.starts_with("is_") {
4082 return true;
4083 }
4084 if matches!(f.name, "status" | "state" | "priority") {
4086 return true;
4087 }
4088 false
4090}
4091
4092pub(crate) fn default_list_columns<T: AdminModel>() -> Vec<&'static str> {
4109 let fields = T::FIELDS;
4110 let mut picked: Vec<&'static str> = Vec::with_capacity(DEFAULT_VISIBLE_COLUMNS);
4111
4112 let mut name_rule_used = false;
4117 for f in fields {
4118 if picked.len() >= DEFAULT_VISIBLE_COLUMNS {
4119 break;
4120 }
4121 let hits_name_rule = !name_rule_used && NAME_LIKE_FIELDS.contains(&f.name);
4122 if is_primary_column(f) || hits_name_rule {
4123 picked.push(f.name);
4124 if hits_name_rule {
4125 name_rule_used = true;
4126 }
4127 }
4128 }
4129
4130 picked
4131}
4132
4133fn field_label(f: &AdminField) -> String {
4138 let base = humanise(f.name);
4139 if f.relation.is_some() {
4140 base.strip_suffix(" Id").map(str::to_string).unwrap_or(base)
4141 } else {
4142 base
4143 }
4144}
4145
4146fn render_meta<T: AdminModel>(id: i64, item: &T) -> String {
4149 let mut items = vec![format!(
4150 r#"<div class="rio-meta-item">
4151<span class="rio-meta-label">ID</span>
4152<span class="rio-meta-value">#{id}</span>
4153</div>"#,
4154 )];
4155
4156 for f in T::FIELDS.iter() {
4157 if f.editable || f.name == "id" {
4158 continue;
4159 }
4160 let value = item.field_display(f.name).unwrap_or_default();
4161 let shown = if value.is_empty() {
4162 "—".to_string()
4163 } else {
4164 value
4165 };
4166 items.push(format!(
4167 r#"<div class="rio-meta-item">
4168<span class="rio-meta-label">{label}</span>
4169<span class="rio-meta-value">{value}</span>
4170</div>"#,
4171 label = escape_html(&humanise(f.name)),
4172 value = escape_html(&shown),
4173 ));
4174 }
4175
4176 format!(r#"<div class="rio-meta">{}</div>"#, items.join(""))
4177}
4178
4179fn render_field<T: AdminModel>(
4184 f: &AdminField,
4185 item: Option<&T>,
4186 placeholder: Option<&str>,
4187 form_options: &FormRelationOptions,
4188) -> String {
4189 let current = item
4190 .and_then(|i| i.field_display(f.name))
4191 .unwrap_or_default();
4192 let n = escape_html(f.name);
4193 let v = escape_html(¤t);
4194
4195 let required = if !f.nullable && !matches!(f.ty, FieldType::Bool) {
4196 " required"
4197 } else {
4198 ""
4199 };
4200
4201 let placeholder_attr = match placeholder {
4202 Some(p) if !p.is_empty() => format!(r#" placeholder="{}""#, escape_html(p)),
4203 _ => String::new(),
4204 };
4205
4206 if matches!(f.ty, FieldType::I32 | FieldType::I64) {
4212 if let Some(options) = form_options.get(f.name) {
4213 let none_opt = if f.nullable {
4214 r#"<option value="">— none —</option>"#
4215 } else {
4216 r#"<option value="" disabled selected>Select…</option>"#
4217 };
4218 let options_html: String = options
4219 .iter()
4220 .map(|(id, label)| {
4221 let selected = if current == id.to_string() {
4222 " selected"
4223 } else {
4224 ""
4225 };
4226 format!(
4227 r#"<option value="{id}"{selected}>{label}</option>"#,
4228 id = id,
4229 selected = selected,
4230 label = escape_html(label),
4231 )
4232 })
4233 .collect();
4234 let none_opt = if !current.is_empty() && !f.nullable {
4238 r#"<option value="" disabled>Select…</option>"#
4239 } else {
4240 none_opt
4241 };
4242 return format!(
4243 r#"<select class="rio-input rio-select" id="_{n}" name="{n}"{required}>{none}{opts}</select>"#,
4244 n = n,
4245 required = required,
4246 none = none_opt,
4247 opts = options_html,
4248 );
4249 }
4250 }
4251
4252 match f.ty {
4253 FieldType::Bool => format!(
4254 r#"<input class="rio-checkbox" id="_{n}" type="checkbox" name="{n}" {checked}>"#,
4255 checked = if current == "true" { "checked" } else { "" },
4256 ),
4257 FieldType::I32 | FieldType::I64 => {
4258 format!(
4259 r#"<input class="rio-input" id="_{n}" type="number" name="{n}" value="{v}"{required}{placeholder_attr}>"#
4260 )
4261 }
4262 FieldType::String => {
4263 format!(
4264 r#"<input class="rio-input" id="_{n}" type="text" name="{n}" value="{v}"{required}{placeholder_attr}>"#
4265 )
4266 }
4267 FieldType::DateTime => {
4268 format!(
4269 r#"<input class="rio-input" id="_{n}" type="datetime-local" name="{n}" value="{v}"{required}{placeholder_attr}>"#
4270 )
4271 }
4272 }
4273}
4274
4275fn delete_confirmation_response<T: AdminModel>(shell: Shell<'_>, id: i64, item: &T) -> Response {
4280 let singular = T::singular_name();
4281 let plural = T::DISPLAY_NAME;
4282 let admin_name = T::ADMIN_NAME;
4283
4284 let summary = T::FIELDS
4288 .iter()
4289 .find(|f| f.editable && matches!(f.ty, FieldType::String))
4290 .and_then(|f| item.field_display(f.name))
4291 .filter(|s| !s.is_empty())
4292 .unwrap_or_else(|| format!("#{id}"));
4293
4294 let csrf_hidden = csrf_input(shell.csrf);
4295
4296 let ctx = intelligence::context_global();
4300 let has_pii = T::FIELDS
4301 .iter()
4302 .any(|f| intelligence::field_ui_metadata(f, ctx).sensitive);
4303 let pii_banner = if has_pii {
4304 let note = if ctx.is_some_and(|c| c.requires_gdpr()) {
4305 "This record contains personal data (GDPR). Deletion is typically irreversible — verify you have the right to erase."
4306 } else {
4307 "This record contains fields flagged as personal data. Review before proceeding."
4308 };
4309 format!(
4310 r#"<div class="rio-alert rio-alert-error">{icon}<div><strong>Sensitive data.</strong> {note}</div></div>"#,
4311 icon = icon_shield_alert(),
4312 note = escape_html(note),
4313 )
4314 } else {
4315 String::new()
4316 };
4317
4318 let body = format!(
4319 r#"<div class="rio-card">
4320<div class="rio-card-body">
4321{pii_banner}
4322<div class="rio-alert rio-alert-warn">
4323{warn}
4324<div>
4325<strong>This action cannot be undone.</strong>
4326Deleting this record removes it permanently. Rows that reference it via a foreign key with <code>ON DELETE CASCADE</code> will be deleted too.
4327</div>
4328</div>
4329<p>You are about to delete <strong>{singular}</strong>:</p>
4330<div class="rio-meta">
4331<div class="rio-meta-item">
4332<span class="rio-meta-label">ID</span>
4333<span class="rio-meta-value">#{id}</span>
4334</div>
4335<div class="rio-meta-item">
4336<span class="rio-meta-label">Summary</span>
4337<span class="rio-meta-value">{summary}</span>
4338</div>
4339</div>
4340</div>
4341<div class="rio-form-footer">
4342<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back}<span>Back to {plural_lower}</span></a>
4343<div class="rio-footer-actions">
4344<a class="rio-btn" href="/admin/{name}/{id}/edit">Cancel</a>
4345<form class="rio-inline-form" method="post" action="/admin/{name}/{id}/delete">
4346{csrf}
4347<button class="rio-btn rio-btn-danger" type="submit">{trash}<span>Delete {singular}</span></button>
4348</form>
4349</div>
4350</div>
4351</div>"#,
4352 warn = icon_triangle_alert(),
4353 singular = escape_html(singular),
4354 id = id,
4355 summary = escape_html(&summary),
4356 name = escape_html(admin_name),
4357 back = icon_arrow_left(),
4358 plural_lower = escape_html(&plural.to_lowercase()),
4359 csrf = csrf_hidden,
4360 trash = icon_trash(),
4361 );
4362
4363 let plural_href = format!("/admin/{admin_name}");
4364 let crumbs: &[Crumb<'_>] = &[
4365 ("Admin", Some("/admin")),
4366 (plural, Some(&plural_href)),
4367 ("Delete", None),
4368 ];
4369
4370 render_shell_page(
4371 &shell,
4372 200,
4373 &format!("Delete {singular}"),
4374 &format!("Delete {singular}?"),
4375 Some("Confirm you want to remove this record."),
4376 crumbs,
4377 "",
4378 &body,
4379 )
4380}
4381
4382fn bulk_delete_confirmation_response<T: AdminModel>(
4387 shell: &Shell<'_>,
4388 items: &[(i64, String)],
4389) -> Response {
4390 let singular = T::singular_name();
4391 let plural = T::DISPLAY_NAME;
4392 let admin_name = T::ADMIN_NAME;
4393 let csrf_hidden = csrf_input(shell.csrf);
4394
4395 let count = items.len();
4396 let count_label = if count == 1 {
4397 format!("1 {}", singular.to_lowercase())
4398 } else {
4399 format!("{count} {}", plural.to_lowercase())
4400 };
4401
4402 let selected_csv: String = items
4405 .iter()
4406 .map(|(id, _)| id.to_string())
4407 .collect::<Vec<_>>()
4408 .join(",");
4409 let rows: String = items
4410 .iter()
4411 .map(|(id, primary)| {
4412 let label = if primary.is_empty() {
4413 format!("#{id}")
4414 } else {
4415 format!("#{id} · {primary}")
4416 };
4417 format!(
4418 r#"<li class="rio-bulk-item">{label}</li>"#,
4419 label = escape_html(&label),
4420 )
4421 })
4422 .collect();
4423
4424 let body = format!(
4425 r#"<div class="rio-card">
4426<div class="rio-card-body">
4427<div class="rio-alert rio-alert-warn">
4428{warn}
4429<div>
4430<strong>This action cannot be undone.</strong>
4431You are about to delete <strong>{count_label}</strong>. Each record removed here is logged individually in <a href="/admin/actions">Recent actions</a>.
4432</div>
4433</div>
4434<p>Review the list, then confirm:</p>
4435<ul class="rio-bulk-list">{rows}</ul>
4436</div>
4437<form method="post" action="/admin/{name}/bulk_action" class="rio-form-footer">
4438{csrf}
4439<input type="hidden" name="action" value="delete">
4440<input type="hidden" name="_selected" value="{selected}">
4441<input type="hidden" name="_confirm" value="yes">
4442<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back}<span>Cancel</span></a>
4443<div class="rio-footer-actions">
4444<button class="rio-btn rio-btn-danger" type="submit">{trash}<span>Yes, delete {count_label}</span></button>
4445</div>
4446</form>
4447</div>"#,
4448 warn = icon_triangle_alert(),
4449 count_label = escape_html(&count_label),
4450 rows = rows,
4451 name = escape_html(admin_name),
4452 csrf = csrf_hidden,
4453 selected = escape_html(&selected_csv),
4454 back = icon_arrow_left(),
4455 trash = icon_trash(),
4456 );
4457
4458 let plural_href = format!("/admin/{admin_name}");
4459 let crumbs: &[Crumb<'_>] = &[
4460 ("Admin", Some("/admin")),
4461 (plural, Some(&plural_href)),
4462 ("Delete selected", None),
4463 ];
4464
4465 render_shell_page(
4466 shell,
4467 200,
4468 &format!("Delete selected {}", plural.to_lowercase()),
4469 &format!("Delete selected {}?", plural.to_lowercase()),
4470 Some("Confirm you want to remove these records."),
4471 crumbs,
4472 "",
4473 &body,
4474 )
4475}
4476
4477fn new_request_id() -> String {
4482 use rand::RngCore as _;
4483 let mut buf = [0u8; 6];
4484 rand::rngs::OsRng.fill_bytes(&mut buf);
4485 let mut s = String::with_capacity(12);
4486 for b in buf {
4487 s.push_str(&format!("{b:02x}"));
4488 }
4489 s
4490}
4491
4492fn error_shell<'a>(
4495 entries: &'a [AdminEntry],
4496 email: Option<&'a str>,
4497 csrf: Option<&'a str>,
4498) -> Shell<'a> {
4499 Shell {
4500 entries,
4501 active: None,
4502 user_email: email,
4503 csrf,
4504 }
4505}
4506
4507fn admin_not_found_response(
4513 _entries: &[AdminEntry],
4514 _email: Option<&str>,
4515 csrf: Option<&str>,
4516) -> Response {
4517 let design = design::Design::global();
4518 let env = crate::admin::templating::env();
4519 let body = match env.get_template("auth/not_found.html").and_then(|tmpl| {
4520 tmpl.render(minijinja::context! {
4521 design => minijinja::context! {
4522 project_name => design.project_name.as_str(),
4523 logo_initial => design.logo_initial.as_str(),
4524 },
4525 csrf_token => csrf.unwrap_or(""),
4526 })
4527 }) {
4528 Ok(html) => html,
4529 Err(err) => {
4530 eprintln!("admin not-found template render failed: {err}");
4531 format!(
4532 "<!doctype html><html><head><meta charset=\"utf-8\"><title>404 Not Found · {p}</title></head><body style=\"font-family:system-ui;max-width:28rem;margin:4rem auto;padding:0 1rem;text-align:center\"><p>404 Not Found</p><h1>We couldn't find that page.</h1><p><a href=\"/admin\">Back to dashboard</a></p></body></html>",
4533 p = escape_html(&design.project_name),
4534 )
4535 }
4536 };
4537 let resp = hyper::Response::builder()
4538 .status(404)
4539 .header("content-type", "text/html; charset=utf-8")
4540 .body(Full::new(Bytes::from(body)))
4541 .expect("valid response");
4542 with_admin_headers(resp)
4543}
4544
4545fn admin_server_error_response(
4546 entries: &[AdminEntry],
4547 email: Option<&str>,
4548 csrf: Option<&str>,
4549 request_id: &str,
4550) -> Response {
4551 let shell = error_shell(entries, email, csrf);
4552 let when = Utc::now().format("%Y-%m-%d %H:%M UTC").to_string();
4553 let body = format!(
4554 r#"<div class="rio-card">
4555<div class="rio-card-body">
4556<div class="rio-alert rio-alert-error">
4557{icon}
4558<div>
4559<strong>Something went wrong.</strong>
4560The admin could not complete your request. The detail has been logged server-side; the summary below is what to share when reporting.
4561</div>
4562</div>
4563<div class="rio-meta">
4564<div class="rio-meta-item">
4565<span class="rio-meta-label">Request ID</span>
4566<span class="rio-meta-value"><code>{rid}</code></span>
4567</div>
4568<div class="rio-meta-item">
4569<span class="rio-meta-label">Timestamp</span>
4570<span class="rio-meta-value">{when}</span>
4571</div>
4572</div>
4573<div class="rio-error-actions">
4574<a class="rio-btn" href="/admin">{back}<span>Back to dashboard</span></a>
4575</div>
4576</div>
4577</div>"#,
4578 icon = icon_triangle_alert(),
4579 rid = escape_html(request_id),
4580 when = escape_html(&when),
4581 back = icon_arrow_left(),
4582 );
4583 let crumbs: &[Crumb<'_>] = &[("Admin", Some("/admin")), ("Server error", None)];
4584 render_shell_page(
4585 &shell,
4586 500,
4587 "500 Server Error",
4588 "500 · Server error",
4589 Some("The admin could not complete your request."),
4590 crumbs,
4591 "",
4592 &body,
4593 )
4594}
4595
4596async fn admin_model_index_get(
4607 db: &Db,
4608 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4609 legacy_entries: &[AdminEntry],
4610 req: Request,
4611 params: crate::router::Params,
4612) -> Result<Response, Error> {
4613 if let Err(resp) = admin_guard(req.ctx()) {
4614 return Ok(resp);
4615 }
4616 let model_slug = params.get("model").unwrap_or("").to_string();
4617
4618 enum ResolvedModel {
4623 New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
4624 Legacy(crate::admin::layout::LegacyEntryModel),
4625 }
4626 let resolved = if let Some(model) = registry.get(&model_slug) {
4627 ResolvedModel::New(model)
4628 } else if let Some(entry) = legacy_entries
4629 .iter()
4630 .find(|e| !e.core && e.admin_name == model_slug)
4631 {
4632 ResolvedModel::Legacy(crate::admin::layout::LegacyEntryModel::new(entry))
4633 } else {
4634 return Err(Error::NotFound);
4635 };
4636 let q_map = req.query().into_map();
4637 let id = q_map.get("id").filter(|s| !s.is_empty()).cloned();
4638 let query = q_map
4639 .get("q")
4640 .map(|s| s.trim())
4641 .filter(|s| !s.is_empty())
4642 .map(String::from);
4643 let page = q_map
4644 .get("page")
4645 .and_then(|p| p.parse::<i64>().ok())
4646 .filter(|p| *p > 0)
4647 .unwrap_or(1);
4648 let sort = q_map.get("sort").filter(|s| !s.is_empty()).cloned();
4649 let dir = q_map.get("dir").filter(|s| !s.is_empty()).cloned();
4650 let filters: std::collections::HashMap<String, String> = q_map
4651 .iter()
4652 .filter(|(k, v)| {
4653 !v.is_empty()
4654 && k.as_str() != "q"
4655 && k.as_str() != "page"
4656 && k.as_str() != "id"
4657 && k.as_str() != "sort"
4658 && k.as_str() != "dir"
4659 && k.as_str() != "advanced"
4660 })
4661 .map(|(k, v)| (k.clone(), v.clone()))
4662 .collect();
4663 let _ = q_map
4664 .get("advanced")
4665 .map(|s| !s.is_empty())
4666 .unwrap_or(false);
4667 let _ = id;
4672 let identity = crate::auth::identity(req.ctx()).cloned();
4673 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
4674 let html = match &resolved {
4675 ResolvedModel::New(model) => {
4676 crate::admin::layout::list_render(
4677 db,
4678 registry,
4679 legacy_entries,
4680 &**model,
4681 None,
4682 query.as_deref(),
4683 page,
4684 &filters,
4685 sort.as_deref(),
4686 dir.as_deref(),
4687 identity.as_ref(),
4688 csrf.as_deref(),
4689 )
4690 .await
4691 }
4692 ResolvedModel::Legacy(model) => {
4693 let source = model.source_entry().clone();
4694 crate::admin::layout::list_render(
4695 db,
4696 registry,
4697 legacy_entries,
4698 model,
4699 Some(&source),
4700 query.as_deref(),
4701 page,
4702 &filters,
4703 sort.as_deref(),
4704 dir.as_deref(),
4705 identity.as_ref(),
4706 csrf.as_deref(),
4707 )
4708 .await
4709 }
4710 };
4711 Ok(with_admin_headers(crate::http::html(html)))
4712}
4713
4714async fn admin_model_form_get(
4720 db: &Db,
4721 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4722 legacy_entries: &[AdminEntry],
4723 req: Request,
4724 params: crate::router::Params,
4725 editing_id: Option<&str>,
4726) -> Result<Response, Error> {
4727 if let Err(resp) = admin_guard(req.ctx()) {
4728 return Ok(resp);
4729 }
4730 let model_slug = params.get("model").unwrap_or("").to_string();
4731
4732 enum ResolvedModel {
4733 New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
4734 Legacy(crate::admin::layout::LegacyEntryModel),
4735 }
4736 let resolved = if let Some(model) = registry.get(&model_slug) {
4737 ResolvedModel::New(model)
4738 } else if let Some(entry) = legacy_entries
4739 .iter()
4740 .find(|e| !e.core && e.admin_name == model_slug)
4741 {
4742 ResolvedModel::Legacy(crate::admin::layout::LegacyEntryModel::new(entry))
4743 } else {
4744 return Err(Error::NotFound);
4745 };
4746
4747 let identity = crate::auth::identity(req.ctx()).cloned();
4748 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
4749 let html = match &resolved {
4750 ResolvedModel::New(model) => {
4751 crate::admin::layout::form_render(
4752 db,
4753 registry,
4754 legacy_entries,
4755 &**model,
4756 None,
4757 editing_id,
4758 identity.as_ref(),
4759 csrf.as_deref(),
4760 None,
4761 )
4762 .await
4763 }
4764 ResolvedModel::Legacy(model) => {
4765 let source = model.source_entry().clone();
4766 crate::admin::layout::form_render(
4767 db,
4768 registry,
4769 legacy_entries,
4770 model,
4771 Some(&source),
4772 editing_id,
4773 identity.as_ref(),
4774 csrf.as_deref(),
4775 None,
4776 )
4777 .await
4778 }
4779 };
4780 Ok(with_admin_headers(crate::http::html(html)))
4781}
4782
4783fn resolve_form_model(
4793 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4794 legacy_entries: &[AdminEntry],
4795 slug: &str,
4796) -> Result<FormResolvedModel, Error> {
4797 if let Some(model) = registry.get(slug) {
4798 return Ok(FormResolvedModel::New(model));
4799 }
4800 if let Some(entry) = legacy_entries
4801 .iter()
4802 .find(|e| !e.core && e.admin_name == slug)
4803 {
4804 return Ok(FormResolvedModel::Legacy(
4805 crate::admin::layout::LegacyEntryModel::new(entry),
4806 ));
4807 }
4808 Err(Error::NotFound)
4809}
4810
4811enum FormResolvedModel {
4812 New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
4813 Legacy(crate::admin::layout::LegacyEntryModel),
4814}
4815
4816impl FormResolvedModel {
4817 fn as_ui_model(&self) -> &dyn crate::admin::admin_form_bridge::AdminUiModel {
4818 match self {
4819 FormResolvedModel::New(m) => &**m,
4820 FormResolvedModel::Legacy(m) => m,
4821 }
4822 }
4823
4824 fn legacy_source(&self) -> Option<&AdminEntry> {
4829 match self {
4830 FormResolvedModel::New(_) => None,
4831 FormResolvedModel::Legacy(m) => Some(m.source_entry()),
4832 }
4833 }
4834}
4835
4836fn build_mutation_data(
4841 model: &dyn crate::admin::admin_form_bridge::AdminUiModel,
4842 form: &FormData,
4843) -> std::collections::HashMap<String, String> {
4844 let pk = model.primary_key();
4845 let mut out = std::collections::HashMap::new();
4846 for field in model.fields() {
4847 if field.name == pk || field.readonly {
4848 continue;
4849 }
4850 let value = form.get(field.name).unwrap_or("");
4851 out.insert(field.name.to_string(), value.to_string());
4852 }
4853 out
4854}
4855
4856async fn admin_model_create_post(
4857 db: &Db,
4858 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4859 legacy_entries: &[AdminEntry],
4860 req: Request,
4861 params: crate::router::Params,
4862) -> Result<Response, Error> {
4863 if let Err(resp) = admin_guard(req.ctx()) {
4864 return Ok(resp);
4865 }
4866 let model_slug = params.get("model").unwrap_or("").to_string();
4867 let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
4868
4869 let (_, body, ctx) = req.into_parts();
4870 let form = read_form_from_parts(body).await?;
4871 require_csrf(&ctx, &form)?;
4872
4873 let data = build_mutation_data(resolved.as_ui_model(), &form);
4874 match crate::admin::persistence::insert_record(db, resolved.as_ui_model().table_name(), &data)
4875 .await
4876 {
4877 Ok(_) => Ok(with_admin_headers(redirect(&format!(
4878 "/admin/{model_slug}"
4879 )))),
4880 Err(e) => {
4881 let identity = crate::auth::identity(&ctx).cloned();
4882 let csrf = ctx_csrf(&ctx).map(str::to_string);
4883 let error_msg = format!("Could not create: {e}");
4884 let source = resolved.legacy_source().cloned();
4885 let html = crate::admin::layout::form_render(
4886 db,
4887 registry,
4888 legacy_entries,
4889 resolved.as_ui_model(),
4890 source.as_ref(),
4891 None,
4892 identity.as_ref(),
4893 csrf.as_deref(),
4894 Some(&error_msg),
4895 )
4896 .await;
4897 Ok(with_admin_headers(crate::http::html(html)))
4898 }
4899 }
4900}
4901
4902async fn admin_model_update_post(
4903 db: &Db,
4904 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4905 legacy_entries: &[AdminEntry],
4906 req: Request,
4907 params: crate::router::Params,
4908) -> Result<Response, Error> {
4909 if let Err(resp) = admin_guard(req.ctx()) {
4910 return Ok(resp);
4911 }
4912 let model_slug = params.get("model").unwrap_or("").to_string();
4913 let id = params.get("id").unwrap_or("").to_string();
4914 if id.is_empty() {
4915 return Err(Error::BadRequest("missing id".into()));
4916 }
4917 let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
4918
4919 let (_, body, ctx) = req.into_parts();
4920 let form = read_form_from_parts(body).await?;
4921 require_csrf(&ctx, &form)?;
4922
4923 let data = build_mutation_data(resolved.as_ui_model(), &form);
4924 match crate::admin::persistence::update_record(
4925 db,
4926 resolved.as_ui_model().table_name(),
4927 &id,
4928 &data,
4929 )
4930 .await
4931 {
4932 Ok(_) => Ok(with_admin_headers(redirect(&format!(
4933 "/admin/{model_slug}"
4934 )))),
4935 Err(e) => {
4936 let identity = crate::auth::identity(&ctx).cloned();
4937 let csrf = ctx_csrf(&ctx).map(str::to_string);
4938 let error_msg = format!("Could not update: {e}");
4939 let source = resolved.legacy_source().cloned();
4940 let html = crate::admin::layout::form_render(
4941 db,
4942 registry,
4943 legacy_entries,
4944 resolved.as_ui_model(),
4945 source.as_ref(),
4946 Some(&id),
4947 identity.as_ref(),
4948 csrf.as_deref(),
4949 Some(&error_msg),
4950 )
4951 .await;
4952 Ok(with_admin_headers(crate::http::html(html)))
4953 }
4954 }
4955}
4956
4957async fn admin_model_delete_post(
4958 db: &Db,
4959 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4960 legacy_entries: &[AdminEntry],
4961 req: Request,
4962 params: crate::router::Params,
4963) -> Result<Response, Error> {
4964 if let Err(resp) = admin_guard(req.ctx()) {
4965 return Ok(resp);
4966 }
4967 let model_slug = params.get("model").unwrap_or("").to_string();
4968 let id = params.get("id").unwrap_or("").to_string();
4969 if id.is_empty() {
4970 return Err(Error::BadRequest("missing id".into()));
4971 }
4972 let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
4973
4974 let (_, body, ctx) = req.into_parts();
4975 let form = read_form_from_parts(body).await?;
4976 require_csrf(&ctx, &form)?;
4977
4978 crate::admin::persistence::bulk_delete(
4979 db,
4980 resolved.as_ui_model().table_name(),
4981 std::slice::from_ref(&id),
4982 )
4983 .await?;
4984 Ok(with_admin_headers(redirect(&format!(
4985 "/admin/{model_slug}"
4986 ))))
4987}
4988
4989#[allow(clippy::result_large_err)]
4994fn admin_guard(ctx: &crate::context::Context) -> Result<(), Response> {
4995 match crate::auth::require_admin(ctx) {
4996 Ok(_) => Ok(()),
4997 Err(Error::Unauthorized) => Err(login_page(401, None, None)),
4998 Err(Error::Forbidden) => Err(forbidden_page(ctx_csrf(ctx))),
4999 Err(other) => Err(other.into_response()),
5000 }
5001}
5002
5003fn login_page(status: u16, email: Option<&str>, error: Option<&str>) -> Response {
5014 let design = design::Design::global();
5015 let env = crate::admin::templating::env();
5016 let body = match env.get_template("auth/login.html").and_then(|tmpl| {
5017 tmpl.render(minijinja::context! {
5018 design => minijinja::context! {
5019 project_name => design.project_name.as_str(),
5020 logo_initial => design.logo_initial.as_str(),
5021 },
5022 email => email.unwrap_or(""),
5023 error => error,
5024 })
5025 }) {
5026 Ok(html) => html,
5027 Err(err) => {
5028 eprintln!("admin login template render failed: {err}");
5029 login_page_fallback(&design.project_name, email, error)
5030 }
5031 };
5032
5033 let resp = hyper::Response::builder()
5034 .status(status)
5035 .header("content-type", "text/html; charset=utf-8")
5036 .body(Full::new(Bytes::from(body)))
5037 .expect("valid response");
5038 with_admin_headers(resp)
5039}
5040
5041fn login_page_fallback(project_name: &str, email: Option<&str>, error: Option<&str>) -> String {
5046 let project = escape_html(project_name);
5047 let email = email.map(escape_html).unwrap_or_default();
5048 let error_block = match error {
5049 Some(msg) => format!(r#"<p style="color:#b91c1c">{}</p>"#, escape_html(msg)),
5050 None => String::new(),
5051 };
5052 format!(
5053 r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Sign in · {project}</title></head><body style="font-family:system-ui;max-width:20rem;margin:4rem auto;padding:0 1rem">
5054<h1>Sign in</h1><p>{project}</p>{error_block}
5055<form method="post" action="/admin/login">
5056<p><label>Email<br><input type="email" name="email" value="{email}" autofocus required></label></p>
5057<p><label>Password<br><input type="password" name="password" required></label></p>
5058<p><button type="submit">Sign in</button></p>
5059</form></body></html>"#,
5060 )
5061}
5062
5063fn forbidden_page(csrf: Option<&str>) -> Response {
5067 let design = design::Design::global();
5068 let env = crate::admin::templating::env();
5069 let body = match env.get_template("auth/forbidden.html").and_then(|tmpl| {
5070 tmpl.render(minijinja::context! {
5071 design => minijinja::context! {
5072 project_name => design.project_name.as_str(),
5073 logo_initial => design.logo_initial.as_str(),
5074 },
5075 csrf_token => csrf.unwrap_or(""),
5076 })
5077 }) {
5078 Ok(html) => html,
5079 Err(err) => {
5080 eprintln!("admin forbidden template render failed: {err}");
5081 forbidden_page_fallback(&design.project_name, csrf)
5082 }
5083 };
5084 let resp = hyper::Response::builder()
5085 .status(403)
5086 .header("content-type", "text/html; charset=utf-8")
5087 .body(Full::new(Bytes::from(body)))
5088 .expect("valid response");
5089 with_admin_headers(resp)
5090}
5091
5092fn forbidden_page_fallback(project_name: &str, csrf: Option<&str>) -> String {
5096 let project = escape_html(project_name);
5097 let csrf_input_html = match csrf {
5098 Some(token) if !token.is_empty() => format!(
5099 r#"<input type="hidden" name="_csrf" value="{}">"#,
5100 escape_html(token)
5101 ),
5102 _ => String::new(),
5103 };
5104 format!(
5105 r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><title>403 Forbidden · {project}</title></head><body style="font-family:system-ui;max-width:28rem;margin:4rem auto;padding:0 1rem;text-align:center">
5106<p>403 Forbidden</p><h1>You're signed in, but you don't have admin access.</h1>
5107<form method="post" action="/admin/logout">{csrf_input_html}<button type="submit">Sign out</button></form>
5108</body></html>"#,
5109 )
5110}
5111
5112fn logout_confirmation_response(signed_in: bool, csrf: Option<&str>) -> Response {
5137 let d = design::Design::global();
5138
5139 let theme_style = format!(
5140 "\n:root {{\n --rio-primary: {p};\n --rio-accent: {a};\n}}\n",
5141 p = escape_css_color(&d.primary_color),
5142 a = escape_css_color(&d.accent_color),
5143 );
5144
5145 let card_body = if signed_in {
5146 let csrf_hidden = csrf_input(csrf);
5147 format!(
5148 r#"<h1 class="rio-auth-title">Sign out</h1>
5149<p class="rio-auth-subtitle">You're about to sign out of the admin.</p>
5150<form method="post" action="/admin/logout">
5151{csrf}
5152<button class="rio-btn rio-btn-primary rio-btn-block" type="submit">Sign out</button>
5153</form>
5154<p class="rio-auth-footer"><a href="/admin">Cancel and return to the admin</a></p>"#,
5155 csrf = csrf_hidden,
5156 )
5157 } else {
5158 String::from(
5159 r#"<h1 class="rio-auth-title">You have signed out</h1>
5160<p class="rio-auth-subtitle">Thanks for your time. Sessions are already revoked server-side.</p>
5161<a class="rio-btn rio-btn-primary rio-btn-block" href="/admin">Sign in again</a>"#,
5162 )
5163 };
5164
5165 let body = format!(
5166 r#"<!doctype html>
5167<html lang="en">
5168<head>
5169<meta charset="utf-8">
5170<meta name="viewport" content="width=device-width, initial-scale=1">
5171<title>Sign out · {project}</title>
5172<link rel="stylesheet" href="/admin/assets/admin.css?v={css_ver}">
5173<link rel="icon" type="image/svg+xml" href="/admin/assets/favicon.svg">
5174<style>{theme}</style>
5175</head>
5176<body>
5177<div class="rio-auth-shell">
5178<div class="rio-auth-card">
5179<div class="rio-auth-logo">
5180<span class="rio-brand-mark">{logo}</span>
5181<span class="rio-brand-meta">
5182<span class="rio-brand-name">{project}</span>
5183<span class="rio-brand-label">Admin</span>
5184</span>
5185</div>
5186{card_body}
5187</div>
5188</div>
5189</body>
5190</html>"#,
5191 project = escape_html(&d.project_name),
5192 theme = theme_style,
5193 logo = escape_html(&d.logo_initial),
5194 card_body = card_body,
5195 css_ver = ADMIN_CSS_VER,
5196 );
5197
5198 let resp = hyper::Response::builder()
5199 .status(200)
5200 .header("content-type", "text/html; charset=utf-8")
5201 .body(Full::new(Bytes::from(body)))
5202 .expect("valid response");
5203 with_admin_headers(resp)
5204}
5205
5206fn object_history_response<T: AdminModel>(
5216 shell: Shell<'_>,
5217 id: i64,
5218 item: &T,
5219 actions: &[audit::AdminAction],
5220) -> Response {
5221 let plural = T::DISPLAY_NAME;
5222 let singular = T::singular_name();
5223 let admin_name = T::ADMIN_NAME;
5224
5225 let summary = T::FIELDS
5226 .iter()
5227 .find(|f| f.editable && matches!(f.ty, FieldType::String))
5228 .and_then(|f| item.field_display(f.name))
5229 .filter(|s| !s.is_empty())
5230 .unwrap_or_else(|| format!("#{id}"));
5231
5232 let inner = if actions.is_empty() {
5233 format!(
5234 r#"<div class="rio-empty">
5235<div class="rio-empty-icon">{icon}</div>
5236<h3>No change history yet</h3>
5237<p>Every add, change, or delete made through the admin will appear here. This record has no entries yet — the most likely reason is that it predates the audit log, or no one has edited it through the admin.</p>
5238</div>"#,
5239 icon = icon_inbox(),
5240 )
5241 } else {
5242 render_actions_timeline(actions, false)
5243 };
5244
5245 let body = format!(
5246 r#"<div class="rio-card">
5247<div class="rio-card-header">
5248<div>
5249<h2 class="rio-card-title">Change history — {singular_hdr} {summary}</h2>
5250<p class="rio-card-subtitle">Every add / change / delete that happened to this record, newest first.</p>
5251</div>
5252<a class="rio-btn" href="/admin/{name}/{id}/edit">Back to record</a>
5253</div>
5254{inner}
5255</div>"#,
5256 singular_hdr = escape_html(singular),
5257 summary = escape_html(&summary),
5258 name = escape_html(admin_name),
5259 id = id,
5260 inner = inner,
5261 );
5262
5263 let plural_href = format!("/admin/{admin_name}");
5264 let edit_href = format!("/admin/{admin_name}/{id}/edit");
5265 let crumbs: &[Crumb<'_>] = &[
5266 ("Admin", Some("/admin")),
5267 (plural, Some(&plural_href)),
5268 (singular, Some(&edit_href)),
5269 ("History", None),
5270 ];
5271
5272 render_shell_page(
5273 &shell,
5274 200,
5275 &format!("History — {singular} {summary}"),
5276 "Change history",
5277 Some("Every add / change / delete that happened to this record."),
5278 crumbs,
5279 "",
5280 &body,
5281 )
5282}
5283
5284#[allow(clippy::too_many_arguments)]
5306async fn suggestion_review_response(
5307 db: &Db,
5308 registry: &crate::admin::admin_form_bridge::AdminRegistry,
5309 legacy_entries: &[AdminEntry],
5310 identity: Option<&crate::auth::Identity>,
5311 csrf: Option<&str>,
5312 admin_name: &str,
5313 field: &str,
5314 error: Option<&str>,
5315) -> Response {
5316 let ctx = intelligence::context_global();
5317 let effective = entry_builder::entries_effective(legacy_entries);
5318 let Some(suggestion) =
5319 suggestions::find_suggestion_from_entries(&effective, ctx, admin_name, field)
5320 else {
5321 return admin_not_found_response(legacy_entries, None, csrf);
5322 };
5323
5324 let plan_result = match run_planner(&suggestion.prompt, ctx) {
5325 Ok(pr) => pr,
5326 Err(msg) => {
5327 return suggestion_error_response(
5328 db,
5329 registry,
5330 legacy_entries,
5331 identity,
5332 csrf,
5333 &suggestion,
5334 &msg,
5335 )
5336 .await;
5337 }
5338 };
5339 let review = match crate::ai::review_plan(
5340 plan_result.schema_ref(),
5341 &plan_result.plan_result.plan,
5342 ctx,
5343 ) {
5344 Ok(r) => r,
5345 Err(e) => {
5346 return suggestion_error_response(
5347 db,
5348 registry,
5349 legacy_entries,
5350 identity,
5351 csrf,
5352 &suggestion,
5353 &format!("review layer refused: {e}"),
5354 )
5355 .await;
5356 }
5357 };
5358
5359 let can_apply = matches!(review.validation, crate::ai::ValidationOutcome::Valid)
5360 && review.risk != crate::ai::RiskLevel::Critical;
5361
5362 let step_descriptions: Vec<String> = plan_result
5363 .plan_result
5364 .plan
5365 .steps
5366 .iter()
5367 .map(|p| match p {
5368 crate::ai::Primitive::AddField(a) => format!(
5369 "+ Add field <code>{}</code> (<code>{}</code>{}) to <code>{}</code>",
5370 escape_html(&a.field.name),
5371 escape_html(&a.field.ty),
5372 if a.field.nullable { ", nullable" } else { "" },
5373 escape_html(&a.model),
5374 ),
5375 other => escape_html(&format!("{other:?}")),
5376 })
5377 .collect();
5378
5379 let schema_diff_html =
5380 render_schema_diff(plan_result.schema_ref(), &plan_result.plan_result.plan);
5381
5382 let (risk_label, risk_class) = match review.risk {
5383 crate::ai::RiskLevel::Low => ("Low", "success"),
5384 crate::ai::RiskLevel::Medium => ("Medium", "warning"),
5385 crate::ai::RiskLevel::High => ("High", "danger"),
5386 crate::ai::RiskLevel::Critical => ("Critical", "danger"),
5387 };
5388
5389 let (validation_ok, validation_message) = match &review.validation {
5390 crate::ai::ValidationOutcome::Valid => (true, None),
5391 crate::ai::ValidationOutcome::Invalid { step, reason } => (
5392 false,
5393 Some(format!(
5394 "Plan fails at step {step}: {reason}. Regenerate the schema or adjust the plan before applying."
5395 )),
5396 ),
5397 };
5398
5399 let confidence_class = match suggestion.confidence.as_str() {
5400 "High" => "success",
5401 "Medium" => "warning",
5402 _ => "secondary",
5403 };
5404
5405 let view = crate::admin::layout::SuggestionReviewView {
5406 model: suggestion.model_display.clone(),
5407 field: suggestion.field.clone(),
5408 industry: ctx
5409 .and_then(|c| c.industry.as_deref())
5410 .unwrap_or("")
5411 .to_string(),
5412 confidence_label: suggestion.confidence.as_str().to_string(),
5413 confidence_class: confidence_class.to_string(),
5414 apply_url: suggestion.url_path(),
5415 can_apply,
5416 step_descriptions,
5417 schema_diff_html,
5418 explanation: plan_result.plan_result.explanation.clone(),
5419 risk_label: risk_label.to_string(),
5420 risk_class: risk_class.to_string(),
5421 adds_fields: review.impact.adds_fields as u32,
5422 destructive: review.impact.destructive,
5423 validation_ok,
5424 validation_message,
5425 warnings: review.warnings.clone(),
5426 error: error.map(str::to_string),
5427 };
5428
5429 let html = crate::admin::layout::suggestion_review_render(
5430 db,
5431 registry,
5432 legacy_entries,
5433 identity,
5434 csrf,
5435 view,
5436 )
5437 .await;
5438 with_admin_headers(crate::http::html(html))
5439}
5440
5441#[allow(clippy::too_many_arguments)]
5448async fn suggestion_apply_response(
5449 db: &Db,
5450 registry: &crate::admin::admin_form_bridge::AdminRegistry,
5451 legacy_entries: &[AdminEntry],
5452 identity: Option<&crate::auth::Identity>,
5453 csrf: Option<&str>,
5454 admin_name: &str,
5455 field: &str,
5456) -> Response {
5457 let ctx = intelligence::context_global();
5458 let effective = entry_builder::entries_effective(legacy_entries);
5459 let Some(suggestion) =
5460 suggestions::find_suggestion_from_entries(&effective, ctx, admin_name, field)
5461 else {
5462 return admin_not_found_response(legacy_entries, None, csrf);
5463 };
5464 let plan_result = match run_planner(&suggestion.prompt, ctx) {
5465 Ok(pr) => pr,
5466 Err(msg) => {
5467 return suggestion_review_response(
5468 db,
5469 registry,
5470 legacy_entries,
5471 identity,
5472 csrf,
5473 admin_name,
5474 field,
5475 Some(&msg),
5476 )
5477 .await;
5478 }
5479 };
5480 let doc = match crate::ai::build_plan_document(
5481 plan_result.schema_ref(),
5482 &suggestion.prompt,
5483 &plan_result.plan_result,
5484 ctx,
5485 ) {
5486 Ok(d) => d,
5487 Err(e) => {
5488 return suggestion_review_response(
5489 db,
5490 registry,
5491 legacy_entries,
5492 identity,
5493 csrf,
5494 admin_name,
5495 field,
5496 Some(&format!("plan document rejected: {e}")),
5497 )
5498 .await;
5499 }
5500 };
5501 if doc.risk == crate::ai::RiskLevel::Critical {
5502 return suggestion_review_response(
5503 db,
5504 registry,
5505 legacy_entries,
5506 identity,
5507 csrf,
5508 admin_name,
5509 field,
5510 Some("Plan risk is Critical — the safe executor refuses to apply it."),
5511 )
5512 .await;
5513 }
5514 let options = crate::ai::ExecuteOptions::default();
5515 let result =
5516 match crate::ai::execute_plan_document(std::path::Path::new("."), &doc, &options, ctx) {
5517 Ok(r) => r,
5518 Err(e) => {
5519 return suggestion_review_response(
5520 db,
5521 registry,
5522 legacy_entries,
5523 identity,
5524 csrf,
5525 admin_name,
5526 field,
5527 Some(&format!("executor refused: {e}")),
5528 )
5529 .await;
5530 }
5531 };
5532
5533 schema_cache::refresh_best_effort();
5534
5535 let change_lines: Vec<String> = doc.plan.steps.iter().map(describe_applied_step).collect();
5536
5537 let files: Vec<crate::admin::layout::AppliedFileView> = result
5538 .generated_files
5539 .iter()
5540 .map(|f| {
5541 let kind = if f.ends_with(".sql") {
5542 "Created migration"
5543 } else if f.ends_with(".rs") {
5544 "Updated"
5545 } else {
5546 "Wrote"
5547 };
5548 crate::admin::layout::AppliedFileView {
5549 kind: kind.to_string(),
5550 path: f.clone(),
5551 }
5552 })
5553 .collect();
5554
5555 let applied = crate::admin::layout::SuggestionAppliedView {
5556 change_lines,
5557 files,
5558 };
5559 let html = crate::admin::layout::suggestion_applied_render(
5560 db,
5561 registry,
5562 legacy_entries,
5563 identity,
5564 csrf,
5565 applied,
5566 )
5567 .await;
5568 with_admin_headers(crate::http::html(html))
5569}
5570
5571#[allow(clippy::too_many_arguments)]
5579async fn suggestion_error_response(
5580 db: &Db,
5581 registry: &crate::admin::admin_form_bridge::AdminRegistry,
5582 legacy_entries: &[AdminEntry],
5583 identity: Option<&crate::auth::Identity>,
5584 csrf: Option<&str>,
5585 suggestion: &suggestions::Suggestion,
5586 msg: &str,
5587) -> Response {
5588 let ctx = intelligence::context_global();
5589 let confidence_class = match suggestion.confidence.as_str() {
5590 "High" => "success",
5591 "Medium" => "warning",
5592 _ => "secondary",
5593 };
5594 let view = crate::admin::layout::SuggestionReviewView {
5595 model: suggestion.model_display.clone(),
5596 field: suggestion.field.clone(),
5597 industry: ctx
5598 .and_then(|c| c.industry.as_deref())
5599 .unwrap_or("")
5600 .to_string(),
5601 confidence_label: suggestion.confidence.as_str().to_string(),
5602 confidence_class: confidence_class.to_string(),
5603 apply_url: suggestion.url_path(),
5604 can_apply: false,
5605 step_descriptions: Vec::new(),
5606 schema_diff_html: String::new(),
5607 explanation: String::new(),
5608 risk_label: "?".into(),
5609 risk_class: "secondary".into(),
5610 adds_fields: 0,
5611 destructive: false,
5612 validation_ok: false,
5613 validation_message: None,
5614 warnings: Vec::new(),
5615 error: Some(msg.to_string()),
5616 };
5617 let html = crate::admin::layout::suggestion_review_render(
5618 db,
5619 registry,
5620 legacy_entries,
5621 identity,
5622 csrf,
5623 view,
5624 )
5625 .await;
5626 with_admin_headers(crate::http::html(html))
5627}
5628
5629fn render_schema_diff(schema: &crate::schema::Schema, plan: &crate::ai::Plan) -> String {
5637 use std::collections::BTreeSet;
5638
5639 let mut touched: Vec<String> = Vec::new();
5641 for step in &plan.steps {
5642 if let crate::ai::Primitive::AddField(a) = step {
5643 if !touched.contains(&a.model) {
5644 touched.push(a.model.clone());
5645 }
5646 }
5647 }
5648 if touched.is_empty() {
5649 return String::new();
5650 }
5651
5652 let mut out = String::new();
5653 for model_name in &touched {
5654 let Some(model) = schema.models.iter().find(|m| &m.name == model_name) else {
5655 continue;
5656 };
5657 let before: Vec<(String, String)> = model
5659 .fields
5660 .iter()
5661 .map(|f| {
5662 let ty = if f.nullable {
5663 format!("Option<{}>", f.ty)
5664 } else {
5665 f.ty.clone()
5666 };
5667 (f.name.clone(), ty)
5668 })
5669 .collect();
5670 let before_names: BTreeSet<&str> = before.iter().map(|(n, _)| n.as_str()).collect();
5671 let mut added: Vec<(String, String)> = Vec::new();
5672 for step in &plan.steps {
5673 if let crate::ai::Primitive::AddField(a) = step {
5674 if a.model == *model_name && !before_names.contains(a.field.name.as_str()) {
5675 let ty = if a.field.nullable {
5676 format!("Option<{}>", a.field.ty)
5677 } else {
5678 a.field.ty.clone()
5679 };
5680 added.push((a.field.name.clone(), ty));
5681 }
5682 }
5683 }
5684 out.push_str(&format!(
5685 r#"<div class="rio-schema-diff"><h3>Model <code>{}</code></h3><pre>"#,
5686 escape_html(model_name),
5687 ));
5688 for (name, ty) in &before {
5689 out.push_str(&format!(" {}: {}\n", escape_html(name), escape_html(ty),));
5690 }
5691 for (name, ty) in &added {
5692 out.push_str(&format!(
5693 "<span class=\"rio-schema-diff-add\">+ {}: {}</span>\n",
5694 escape_html(name),
5695 escape_html(ty),
5696 ));
5697 }
5698 out.push_str("</pre></div>");
5699 }
5700 out
5701}
5702
5703fn describe_applied_step(p: &crate::ai::Primitive) -> String {
5707 match p {
5708 crate::ai::Primitive::AddField(a) => format!(
5709 "Added field <code>{}</code> (<code>{}</code>{}) to <code>{}</code>",
5710 escape_html(&a.field.name),
5711 escape_html(&a.field.ty),
5712 if a.field.nullable { ", nullable" } else { "" },
5713 escape_html(&a.model),
5714 ),
5715 crate::ai::Primitive::RenameField(r) => format!(
5716 "Renamed <code>{}.{}</code> to <code>{}</code>",
5717 escape_html(&r.model),
5718 escape_html(&r.from),
5719 escape_html(&r.to),
5720 ),
5721 crate::ai::Primitive::RenameModel(r) => format!(
5722 "Renamed model <code>{}</code> to <code>{}</code>",
5723 escape_html(&r.from),
5724 escape_html(&r.to),
5725 ),
5726 other => escape_html(&format!("{:?}", other)),
5727 }
5728}
5729
5730struct PlannerCallResult {
5734 plan_result: crate::ai::PlanResult,
5735 schema: crate::schema::Schema,
5736}
5737impl PlannerCallResult {
5738 fn schema_ref(&self) -> &crate::schema::Schema {
5739 &self.schema
5740 }
5741}
5742
5743fn run_planner(
5744 prompt: &str,
5745 context: Option<&crate::ai::ContextConfig>,
5746) -> Result<PlannerCallResult, String> {
5747 let schema_path = std::path::Path::new("rustio.schema.json");
5752 let schema_json = std::fs::read_to_string(schema_path)
5753 .map_err(|e| format!("could not read rustio.schema.json: {e}"))?;
5754 let schema = crate::schema::Schema::parse(&schema_json)
5755 .map_err(|e| format!("rustio.schema.json parse error: {e}"))?;
5756 let plan_result = crate::ai::generate_plan(
5757 &schema,
5758 context,
5759 crate::ai::PlanRequest::new(prompt.to_string()),
5760 )
5761 .map_err(|e| format!("planner refused: {e}"))?;
5762 Ok(PlannerCallResult {
5763 plan_result,
5764 schema,
5765 })
5766}
5767
5768fn render_actions_timeline(actions: &[audit::AdminAction], show_object_link: bool) -> String {
5779 if actions.is_empty() {
5780 return String::new();
5781 }
5782 let rows: String = actions
5783 .iter()
5784 .map(|a| {
5785 let action = audit::ActionType::parse(&a.action_type);
5786 let (pill_class, label) = match action {
5787 Some(at) => (at.pill_class(), at.label()),
5788 None => ("rio-pill rio-pill-slate", "Action"),
5789 };
5790 let when = a.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
5791 let who = a
5792 .user_email
5793 .clone()
5794 .unwrap_or_else(|| format!("user #{}", a.user_id));
5795 let ip = match &a.ip_address {
5796 Some(ip) if !ip.is_empty() => {
5797 format!(r#"<span class="rio-audit-ip">{}</span>"#, escape_html(ip))
5798 }
5799 _ => String::new(),
5800 };
5801 let object_link = if show_object_link {
5802 format!(
5803 r#"<a class="rio-audit-object" href="/admin/{name}/{id}/history">{name} #{id}</a>"#,
5804 name = escape_html(&a.model_name),
5805 id = a.object_id,
5806 )
5807 } else {
5808 String::new()
5809 };
5810 format!(
5811 r#"<li class="rio-audit-item">
5812<div class="rio-audit-head">
5813<span class="{pill}">{label}</span>
5814{object_link}
5815<span class="rio-audit-when">{when}</span>
5816</div>
5817<p class="rio-audit-summary">{summary}</p>
5818<div class="rio-audit-meta">
5819<span class="rio-audit-who">{who}</span>
5820{ip}
5821</div>
5822</li>"#,
5823 pill = pill_class,
5824 label = label,
5825 object_link = object_link,
5826 when = escape_html(&when),
5827 summary = escape_html(&a.summary),
5828 who = escape_html(&who),
5829 ip = ip,
5830 )
5831 })
5832 .collect();
5833 format!(r#"<ul class="rio-audit-timeline">{rows}</ul>"#)
5834}
5835
5836fn build_session_cookie(name: &str, token: &str, max_age: i64) -> String {
5841 build_session_cookie_impl(name, token, max_age, crate::auth::in_production())
5842}
5843
5844fn build_session_cookie_impl(name: &str, token: &str, max_age: i64, secure: bool) -> String {
5845 let secure = if secure { "; Secure" } else { "" };
5846 format!("{name}={token}; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age={max_age}")
5847}
5848
5849async fn handle_login(req: Request, db: &crate::orm::Db) -> Result<Response, Error> {
5854 use crate::auth;
5855
5856 let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
5857
5858 let form = read_form(req).await?;
5859 let email = form.get("email").unwrap_or("").trim().to_string();
5860 let password = form.get("password").unwrap_or("").to_string();
5861
5862 if email.is_empty() || password.is_empty() {
5863 return Ok(login_page(
5864 400,
5865 Some(&email),
5866 Some("Email and password are both required."),
5867 ));
5868 }
5869
5870 let email_key = auth::normalise_email(&email);
5871 let rate_key = auth::LoginRateLimiter::compose_key(&email_key, peer_ip.as_deref());
5872 if let Err(remaining) = auth::LoginRateLimiter::global().check(&rate_key) {
5873 return Ok(login_page(
5874 429,
5875 Some(&email),
5876 Some(&format!(
5877 "Too many failed attempts. Try again in {}s.",
5878 remaining.as_secs().max(1),
5879 )),
5880 ));
5881 }
5882
5883 let generic = "Invalid email or password.";
5884
5885 let user = auth::user::find_by_email(db, &email).await?;
5886 let valid = match &user {
5887 Some(u) => auth::password::verify(&password, &u.password_hash),
5888 None => {
5889 let _ = auth::password::verify(&password, auth::dummy_password_hash());
5890 false
5891 }
5892 };
5893
5894 if !valid {
5895 auth::LoginRateLimiter::global().record_failure(&rate_key);
5896 return Ok(login_page(401, Some(&email), Some(generic)));
5897 }
5898
5899 let user = user.expect("valid credentials imply a found user");
5900 if !user.is_active {
5901 return Ok(login_page(
5902 403,
5903 Some(&email),
5904 Some("This account is inactive. Contact an administrator."),
5905 ));
5906 }
5907
5908 auth::LoginRateLimiter::global().record_success(&rate_key);
5909 let _ = auth::session::sweep_expired(db).await;
5910
5911 let session = auth::session::create(db, user.id).await?;
5912
5913 let mut resp = redirect("/admin");
5914 let max_age = auth::SESSION_TTL_DAYS * 24 * 3600;
5915 crate::http::set_cookie(
5916 &mut resp,
5917 &build_session_cookie(auth::SESSION_COOKIE, &session.id, max_age),
5918 );
5919 Ok(with_admin_headers(resp))
5920}
5921
5922async fn handle_logout(req: Request, db: &crate::orm::Db) -> Result<Response, Error> {
5923 use crate::auth;
5924
5925 let cookie_token = req.cookie(auth::SESSION_COOKIE);
5926 let (_, body, ctx) = req.into_parts();
5927 let form = read_form_from_parts(body).await?;
5928 require_csrf(&ctx, &form)?;
5929
5930 if let Some(token) = cookie_token {
5931 let _ = auth::session::delete(db, &token).await;
5932 }
5933
5934 let mut resp = redirect("/admin/logout");
5939 crate::http::set_cookie(
5940 &mut resp,
5941 &build_session_cookie(auth::SESSION_COOKIE, "", 0),
5942 );
5943 Ok(with_admin_headers(resp))
5944}
5945
5946async fn handle_password_change_post(
5961 req: Request,
5962 db: &crate::orm::Db,
5963 registry: &crate::admin::admin_form_bridge::AdminRegistry,
5964 legacy_entries: &[AdminEntry],
5965) -> Result<Response, Error> {
5966 use crate::auth;
5967
5968 let (_, body, ctx) = req.into_parts();
5969 let form = read_form_from_parts(body).await?;
5970 require_csrf(&ctx, &form)?;
5971
5972 let user_id = match ctx.get::<auth::Identity>() {
5975 Some(i) => i.user_id,
5976 None => return Ok(login_page(401, None, None)),
5977 };
5978 let identity = crate::auth::identity(&ctx).cloned();
5979 let csrf = ctx_csrf(&ctx).map(str::to_string);
5980
5981 let old = form.get("old_password").unwrap_or("").to_string();
5982 let new1 = form.get("new_password1").unwrap_or("").to_string();
5983 let new2 = form.get("new_password2").unwrap_or("").to_string();
5984
5985 async fn render_err(
5987 db: &crate::orm::Db,
5988 registry: &crate::admin::admin_form_bridge::AdminRegistry,
5989 legacy_entries: &[AdminEntry],
5990 identity: Option<&crate::auth::Identity>,
5991 csrf: Option<&str>,
5992 msg: &str,
5993 ) -> Response {
5994 let html = crate::admin::layout::password_change_render(
5995 db,
5996 registry,
5997 legacy_entries,
5998 identity,
5999 csrf,
6000 Some(msg),
6001 )
6002 .await;
6003 with_admin_headers(crate::http::html(html))
6004 }
6005
6006 if old.is_empty() || new1.is_empty() || new2.is_empty() {
6007 return Ok(render_err(
6008 db,
6009 registry,
6010 legacy_entries,
6011 identity.as_ref(),
6012 csrf.as_deref(),
6013 "All three fields are required.",
6014 )
6015 .await);
6016 }
6017 if new1 != new2 {
6018 return Ok(render_err(
6019 db,
6020 registry,
6021 legacy_entries,
6022 identity.as_ref(),
6023 csrf.as_deref(),
6024 "The two new password fields did not match. Try again.",
6025 )
6026 .await);
6027 }
6028 if new1.len() < 8 {
6029 return Ok(render_err(
6030 db,
6031 registry,
6032 legacy_entries,
6033 identity.as_ref(),
6034 csrf.as_deref(),
6035 "Your new password must be at least 8 characters.",
6036 )
6037 .await);
6038 }
6039
6040 let user = match auth::user::find_by_id(db, user_id).await? {
6043 Some(u) => u,
6044 None => return Ok(login_page(401, None, None)),
6045 };
6046 if !auth::password::verify(&old, &user.password_hash) {
6047 return Ok(render_err(
6048 db,
6049 registry,
6050 legacy_entries,
6051 identity.as_ref(),
6052 csrf.as_deref(),
6053 "Your old password was entered incorrectly. Please try again.",
6054 )
6055 .await);
6056 }
6057
6058 auth::user::set_password(db, user.id, &new1).await?;
6062 let session = auth::session::create(db, user.id).await?;
6063 let max_age = auth::SESSION_TTL_DAYS * 24 * 3600;
6064 let mut resp = redirect("/admin/password_change/done");
6065 crate::http::set_cookie(
6066 &mut resp,
6067 &build_session_cookie(auth::SESSION_COOKIE, &session.id, max_age),
6068 );
6069 Ok(with_admin_headers(resp))
6070}
6071
6072pub fn parse_datetime_local(raw: &str) -> Result<DateTime<Utc>, String> {
6079 if raw.is_empty() {
6080 return Err(String::from("date-time value is empty"));
6081 }
6082 if raw.trim_matches(|c: char| c.is_ascii_whitespace()) != raw {
6083 return Err(format!("`{raw}` has surrounding whitespace"));
6084 }
6085 if raw.ends_with('Z') || raw.contains('+') || (raw.matches('-').count() > 2) {
6086 return Err(format!(
6087 "`{raw}` looks like a timezoned date-time; expected YYYY-MM-DDTHH:MM"
6088 ));
6089 }
6090
6091 let parsed = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")
6092 .or_else(|_| NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
6093 .map_err(|_| format!("`{raw}` is not a valid date-time"))?;
6094 match Utc.from_local_datetime(&parsed) {
6095 chrono::LocalResult::Single(dt) => Ok(dt),
6096 _ => Err(format!("`{raw}` could not be interpreted as UTC")),
6097 }
6098}
6099
6100fn escape_html(s: &str) -> String {
6101 let mut out = String::with_capacity(s.len());
6102 for ch in s.chars() {
6103 match ch {
6104 '&' => out.push_str("&"),
6105 '<' => out.push_str("<"),
6106 '>' => out.push_str(">"),
6107 '"' => out.push_str("""),
6108 '\'' => out.push_str("'"),
6109 c => out.push(c),
6110 }
6111 }
6112 out
6113}
6114
6115#[cfg(test)]
6116mod tests {
6117 use super::*;
6118 use chrono::Timelike;
6119
6120 #[test]
6127 fn session_cookie_dev_has_no_secure_flag() {
6128 let c = build_session_cookie_impl("rustio_session", "TOK", 600, false);
6129 assert_eq!(
6130 c,
6131 "rustio_session=TOK; Path=/; HttpOnly; SameSite=Strict; Max-Age=600"
6132 );
6133 assert!(!c.contains("Secure"));
6134 }
6135
6136 #[test]
6137 fn session_cookie_production_has_secure_flag() {
6138 let c = build_session_cookie_impl("rustio_session", "TOK", 600, true);
6139 assert_eq!(
6140 c,
6141 "rustio_session=TOK; Path=/; HttpOnly; SameSite=Strict; Secure; Max-Age=600"
6142 );
6143 }
6144
6145 #[test]
6146 fn session_cookie_expiration_shape_is_stable() {
6147 let c = build_session_cookie_impl("rustio_session", "", 0, true);
6148 assert!(c.contains("rustio_session=; "));
6149 assert!(c.contains("HttpOnly"));
6150 assert!(c.contains("SameSite=Strict"));
6151 assert!(c.contains("Secure"));
6152 assert!(c.contains("Max-Age=0"));
6153 }
6154
6155 #[test]
6156 fn escape_html_escapes_dangerous_chars() {
6157 assert_eq!(
6158 escape_html("<script>alert(\"xss\")</script>"),
6159 "<script>alert("xss")</script>"
6160 );
6161 assert_eq!(escape_html("a & b"), "a & b");
6162 assert_eq!(escape_html("it's"), "it's");
6163 }
6164
6165 #[test]
6168 fn escape_css_color_accepts_hex_tokens() {
6169 assert_eq!(escape_css_color("#0f172a"), "#0f172a");
6170 assert_eq!(escape_css_color("#4f46e5"), "#4f46e5");
6171 }
6172
6173 #[test]
6174 fn escape_css_color_rejects_injection_attempts() {
6175 assert_eq!(escape_css_color("red; } body { display:none"), "#0f172a");
6178 assert_eq!(escape_css_color("}</style><script>"), "#0f172a");
6179 assert_eq!(escape_css_color("red\\0A "), "#0f172a");
6180 }
6181
6182 #[test]
6185 fn parse_datetime_local_accepts_minute_precision() {
6186 let dt = parse_datetime_local("2026-04-18T10:12").unwrap();
6187 assert_eq!(dt.to_rfc3339(), "2026-04-18T10:12:00+00:00");
6188 assert!(dt.to_rfc3339().ends_with("+00:00"));
6189 }
6190
6191 #[test]
6192 fn parse_datetime_local_accepts_second_precision() {
6193 let dt = parse_datetime_local("2026-04-18T10:12:33").unwrap();
6194 assert_eq!(dt.to_rfc3339(), "2026-04-18T10:12:33+00:00");
6195 }
6196
6197 #[test]
6198 fn parse_datetime_local_rejects_empty_string() {
6199 assert!(parse_datetime_local("").is_err());
6200 }
6201
6202 #[test]
6203 fn parse_datetime_local_rejects_free_text() {
6204 assert!(parse_datetime_local("tomorrow at noon").is_err());
6205 }
6206
6207 #[test]
6208 fn parse_datetime_local_rejects_partial_date() {
6209 assert!(parse_datetime_local("2026-04-18").is_err());
6210 }
6211
6212 #[test]
6213 fn parse_datetime_local_rejects_out_of_range_date() {
6214 assert!(parse_datetime_local("2026-13-01T00:00").is_err());
6215 assert!(parse_datetime_local("2026-04-31T00:00").is_err());
6216 }
6217
6218 #[test]
6219 fn parse_datetime_local_rejects_out_of_range_time() {
6220 assert!(parse_datetime_local("2026-04-18T25:00").is_err());
6221 assert!(parse_datetime_local("2026-04-18T10:99").is_err());
6222 }
6223
6224 #[test]
6225 fn parse_datetime_local_rejects_surrounding_whitespace() {
6226 assert!(parse_datetime_local(" 2026-04-18T10:12").is_err());
6227 assert!(parse_datetime_local("2026-04-18T10:12 ").is_err());
6228 }
6229
6230 #[test]
6231 fn parse_datetime_local_rejects_timezone_suffix() {
6232 assert!(parse_datetime_local("2026-04-18T10:12Z").is_err());
6233 assert!(parse_datetime_local("2026-04-18T10:12:00+00:00").is_err());
6234 }
6235
6236 struct Widgety;
6239 impl crate::orm::Model for Widgety {
6240 const TABLE: &'static str = "widgety";
6241 const COLUMNS: &'static [&'static str] = &["id"];
6242 const INSERT_COLUMNS: &'static [&'static str] = &[];
6243 fn id(&self) -> i64 {
6244 0
6245 }
6246 fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6247 unimplemented!()
6248 }
6249 fn insert_values(&self) -> Vec<crate::orm::Value> {
6250 Vec::new()
6251 }
6252 }
6253 impl AdminModel for Widgety {
6254 const ADMIN_NAME: &'static str = "widgety";
6255 const DISPLAY_NAME: &'static str = "Widgety";
6256 const FIELDS: &'static [AdminField] = &[];
6257 fn field_display(&self, name: &str) -> Option<String> {
6258 match name {
6259 "filled" => Some(String::from("2026-04-18T10:12")),
6260 "empty" => Some(String::new()),
6261 _ => None,
6262 }
6263 }
6264 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6265 unimplemented!()
6266 }
6267 }
6268
6269 fn string_field(name: &'static str, nullable: bool) -> AdminField {
6270 AdminField {
6271 name,
6272 ty: FieldType::String,
6273 editable: true,
6274 nullable,
6275 relation: None,
6276 }
6277 }
6278
6279 fn datetime_field(name: &'static str, nullable: bool) -> AdminField {
6280 AdminField {
6281 name,
6282 ty: FieldType::DateTime,
6283 editable: true,
6284 nullable,
6285 relation: None,
6286 }
6287 }
6288
6289 #[test]
6290 fn nullable_string_field_omits_required_attribute() {
6291 let f = string_field("note", true);
6292 let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6293 assert!(!html.contains("required"), "html was: {html}");
6294 }
6295
6296 #[test]
6297 fn non_nullable_string_field_marks_required() {
6298 let f = string_field("title", false);
6299 let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6300 assert!(html.contains("required"), "html was: {html}");
6301 }
6302
6303 #[test]
6304 fn bool_field_never_marks_required() {
6305 let f = AdminField {
6306 name: "flag",
6307 ty: FieldType::Bool,
6308 editable: true,
6309 nullable: false,
6310 relation: None,
6311 };
6312 let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6313 assert!(!html.contains("required"), "html was: {html}");
6314 }
6315
6316 #[test]
6317 fn datetime_field_uses_datetime_local_input() {
6318 let f = datetime_field("starts_at", false);
6319 let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6320 assert!(
6321 html.contains(r#"type="datetime-local""#),
6322 "html was: {html}"
6323 );
6324 }
6325
6326 #[test]
6327 fn datetime_field_renders_existing_value() {
6328 let f = datetime_field("filled", true);
6329 let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
6330 assert!(
6331 html.contains(r#"value="2026-04-18T10:12""#),
6332 "html was: {html}"
6333 );
6334 }
6335
6336 #[test]
6337 fn nullable_field_with_none_value_does_not_panic() {
6338 let f = string_field("empty", true);
6339 let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
6340 assert!(html.contains(r#"value="""#));
6341 assert!(!html.contains("required"));
6342 }
6343
6344 #[test]
6345 fn field_display_returning_none_renders_empty_value() {
6346 let f = string_field("unknown_field", false);
6347 let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
6348 assert!(html.contains(r#"value="""#));
6349 }
6350
6351 #[test]
6352 fn parse_datetime_local_enforces_utc_for_every_valid_input() {
6353 let inputs = [
6354 "2000-01-01T00:00",
6355 "2026-04-18T10:12",
6356 "2026-04-18T10:12:33",
6357 "2099-12-31T23:59",
6358 ];
6359 for raw in inputs {
6360 let dt = parse_datetime_local(raw).unwrap_or_else(|e| panic!("`{raw}`: {e}"));
6361 assert!(
6362 dt.to_rfc3339().ends_with("+00:00"),
6363 "non-UTC offset in output for `{raw}`: {}",
6364 dt.to_rfc3339(),
6365 );
6366 assert!(
6367 dt.nanosecond() == 0,
6368 "unexpected sub-second part for `{raw}`"
6369 );
6370 }
6371 }
6372
6373 #[test]
6376 fn humanise_converts_snake_case_to_title_case() {
6377 assert_eq!(humanise("title"), "Title");
6378 assert_eq!(humanise("is_active"), "Is Active");
6379 assert_eq!(humanise("created_at"), "Created At");
6380 assert_eq!(humanise("assigned_to"), "Assigned To");
6381 }
6382
6383 fn make_field(
6389 name: &'static str,
6390 ty: FieldType,
6391 relation: Option<AdminRelation>,
6392 ) -> AdminField {
6393 AdminField {
6394 name,
6395 ty,
6396 editable: true,
6397 nullable: false,
6398 relation,
6399 }
6400 }
6401
6402 fn fk(target: &'static str) -> AdminRelation {
6403 AdminRelation {
6404 kind: crate::schema::RelationKind::BelongsTo,
6405 model: target,
6406 display_field: None,
6407 }
6408 }
6409
6410 #[test]
6411 fn primary_rule_1_id_always_primary() {
6412 assert!(is_primary_column(&make_field("id", FieldType::I64, None)));
6413 }
6414
6415 #[test]
6416 fn primary_rule_4_fk_ending_in_id() {
6417 assert!(is_primary_column(&make_field(
6418 "department_id",
6419 FieldType::I64,
6420 Some(fk("Department")),
6421 )));
6422 assert!(!is_primary_column(&make_field(
6424 "department_id",
6425 FieldType::I64,
6426 None,
6427 )));
6428 assert!(!is_primary_column(&make_field(
6430 "department",
6431 FieldType::I64,
6432 Some(fk("Department")),
6433 )));
6434 }
6435
6436 #[test]
6437 fn primary_rule_5_is_prefix_bool() {
6438 assert!(is_primary_column(&make_field(
6439 "is_active",
6440 FieldType::Bool,
6441 None,
6442 )));
6443 assert!(is_primary_column(&make_field(
6444 "is_admin",
6445 FieldType::Bool,
6446 None,
6447 )));
6448 assert!(!is_primary_column(&make_field(
6450 "active",
6451 FieldType::Bool,
6452 None,
6453 )));
6454 assert!(!is_primary_column(&make_field(
6456 "is_active",
6457 FieldType::String,
6458 None,
6459 )));
6460 }
6461
6462 #[test]
6463 fn primary_rule_6_status_state_priority() {
6464 assert!(is_primary_column(&make_field(
6465 "status",
6466 FieldType::String,
6467 None,
6468 )));
6469 assert!(is_primary_column(&make_field(
6470 "state",
6471 FieldType::String,
6472 None,
6473 )));
6474 assert!(is_primary_column(&make_field(
6475 "priority",
6476 FieldType::I32,
6477 None,
6478 )));
6479 assert!(!is_primary_column(&make_field(
6481 "priorities",
6482 FieldType::I32,
6483 None,
6484 )));
6485 }
6486
6487 #[test]
6488 fn primary_rule_7_plain_fields_not_primary() {
6489 assert!(!is_primary_column(&make_field(
6490 "specialty",
6491 FieldType::String,
6492 None,
6493 )));
6494 assert!(!is_primary_column(&make_field(
6495 "license_no",
6496 FieldType::String,
6497 None,
6498 )));
6499 assert!(!is_primary_column(&make_field(
6500 "years_experience",
6501 FieldType::I32,
6502 None,
6503 )));
6504 assert!(!is_primary_column(&make_field(
6505 "created_at",
6506 FieldType::DateTime,
6507 None,
6508 )));
6509 }
6510
6511 struct DoctorFixture;
6518 impl crate::orm::Model for DoctorFixture {
6519 const TABLE: &'static str = "doctors";
6520 const COLUMNS: &'static [&'static str] = &[
6521 "id",
6522 "full_name",
6523 "specialty",
6524 "department_id",
6525 "license_no",
6526 "email",
6527 "phone",
6528 "years_experience",
6529 "is_active",
6530 "created_at",
6531 ];
6532 const INSERT_COLUMNS: &'static [&'static str] = &[];
6533 fn id(&self) -> i64 {
6534 0
6535 }
6536 fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6537 unimplemented!()
6538 }
6539 fn insert_values(&self) -> Vec<crate::orm::Value> {
6540 Vec::new()
6541 }
6542 }
6543 impl AdminModel for DoctorFixture {
6544 const ADMIN_NAME: &'static str = "doctors";
6545 const DISPLAY_NAME: &'static str = "Doctors";
6546 const FIELDS: &'static [AdminField] = &[
6547 AdminField {
6548 name: "id",
6549 ty: FieldType::I64,
6550 editable: false,
6551 nullable: false,
6552 relation: None,
6553 },
6554 AdminField {
6555 name: "full_name",
6556 ty: FieldType::String,
6557 editable: true,
6558 nullable: false,
6559 relation: None,
6560 },
6561 AdminField {
6562 name: "specialty",
6563 ty: FieldType::String,
6564 editable: true,
6565 nullable: false,
6566 relation: None,
6567 },
6568 AdminField {
6569 name: "department_id",
6570 ty: FieldType::I64,
6571 editable: true,
6572 nullable: false,
6573 relation: Some(AdminRelation {
6574 kind: crate::schema::RelationKind::BelongsTo,
6575 model: "Department",
6576 display_field: None,
6577 }),
6578 },
6579 AdminField {
6580 name: "license_no",
6581 ty: FieldType::String,
6582 editable: true,
6583 nullable: false,
6584 relation: None,
6585 },
6586 AdminField {
6587 name: "email",
6588 ty: FieldType::String,
6589 editable: true,
6590 nullable: false,
6591 relation: None,
6592 },
6593 AdminField {
6594 name: "phone",
6595 ty: FieldType::String,
6596 editable: true,
6597 nullable: false,
6598 relation: None,
6599 },
6600 AdminField {
6601 name: "years_experience",
6602 ty: FieldType::I32,
6603 editable: true,
6604 nullable: false,
6605 relation: None,
6606 },
6607 AdminField {
6608 name: "is_active",
6609 ty: FieldType::Bool,
6610 editable: true,
6611 nullable: false,
6612 relation: None,
6613 },
6614 AdminField {
6615 name: "created_at",
6616 ty: FieldType::DateTime,
6617 editable: false,
6618 nullable: false,
6619 relation: None,
6620 },
6621 ];
6622 fn singular_name() -> &'static str {
6623 "Doctor"
6624 }
6625 fn field_display(&self, _: &str) -> Option<String> {
6626 None
6627 }
6628 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6629 unimplemented!()
6630 }
6631 }
6632
6633 #[test]
6634 fn default_columns_doctor_yields_four_rule_matches() {
6635 let cols = default_list_columns::<DoctorFixture>();
6642 assert_eq!(cols, vec!["id", "full_name", "department_id", "is_active",]);
6643 }
6644
6645 #[test]
6646 fn default_columns_returns_fewer_than_five_when_rules_match_fewer() {
6647 struct Tiny;
6651 impl crate::orm::Model for Tiny {
6652 const TABLE: &'static str = "tinies";
6653 const COLUMNS: &'static [&'static str] = &["id", "name", "is_active"];
6654 const INSERT_COLUMNS: &'static [&'static str] = &[];
6655 fn id(&self) -> i64 {
6656 0
6657 }
6658 fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6659 unimplemented!()
6660 }
6661 fn insert_values(&self) -> Vec<crate::orm::Value> {
6662 Vec::new()
6663 }
6664 }
6665 impl AdminModel for Tiny {
6666 const ADMIN_NAME: &'static str = "tinies";
6667 const DISPLAY_NAME: &'static str = "Tinies";
6668 const FIELDS: &'static [AdminField] = &[
6669 AdminField {
6670 name: "id",
6671 ty: FieldType::I64,
6672 editable: false,
6673 nullable: false,
6674 relation: None,
6675 },
6676 AdminField {
6677 name: "name",
6678 ty: FieldType::String,
6679 editable: true,
6680 nullable: false,
6681 relation: None,
6682 },
6683 AdminField {
6684 name: "is_active",
6685 ty: FieldType::Bool,
6686 editable: true,
6687 nullable: false,
6688 relation: None,
6689 },
6690 ];
6691 fn singular_name() -> &'static str {
6692 "Tiny"
6693 }
6694 fn field_display(&self, _: &str) -> Option<String> {
6695 None
6696 }
6697 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6698 unimplemented!()
6699 }
6700 }
6701 let cols = default_list_columns::<Tiny>();
6702 assert_eq!(cols, vec!["id", "name", "is_active"]);
6703 }
6704
6705 #[test]
6706 fn default_list_columns_caps_at_five() {
6707 struct Stuffed;
6710 impl crate::orm::Model for Stuffed {
6711 const TABLE: &'static str = "stuffed";
6712 const COLUMNS: &'static [&'static str] = &[
6713 "id",
6714 "name",
6715 "status",
6716 "state",
6717 "priority",
6718 "is_active",
6719 "is_admin",
6720 ];
6721 const INSERT_COLUMNS: &'static [&'static str] = &[];
6722 fn id(&self) -> i64 {
6723 0
6724 }
6725 fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6726 unimplemented!()
6727 }
6728 fn insert_values(&self) -> Vec<crate::orm::Value> {
6729 Vec::new()
6730 }
6731 }
6732 impl AdminModel for Stuffed {
6733 const ADMIN_NAME: &'static str = "stuffed";
6734 const DISPLAY_NAME: &'static str = "Stuffed";
6735 const FIELDS: &'static [AdminField] = &[
6736 AdminField {
6737 name: "id",
6738 ty: FieldType::I64,
6739 editable: false,
6740 nullable: false,
6741 relation: None,
6742 },
6743 AdminField {
6744 name: "name",
6745 ty: FieldType::String,
6746 editable: true,
6747 nullable: false,
6748 relation: None,
6749 },
6750 AdminField {
6751 name: "status",
6752 ty: FieldType::String,
6753 editable: true,
6754 nullable: false,
6755 relation: None,
6756 },
6757 AdminField {
6758 name: "state",
6759 ty: FieldType::String,
6760 editable: true,
6761 nullable: false,
6762 relation: None,
6763 },
6764 AdminField {
6765 name: "priority",
6766 ty: FieldType::I32,
6767 editable: true,
6768 nullable: false,
6769 relation: None,
6770 },
6771 AdminField {
6772 name: "is_active",
6773 ty: FieldType::Bool,
6774 editable: true,
6775 nullable: false,
6776 relation: None,
6777 },
6778 AdminField {
6779 name: "is_admin",
6780 ty: FieldType::Bool,
6781 editable: true,
6782 nullable: false,
6783 relation: None,
6784 },
6785 ];
6786 fn singular_name() -> &'static str {
6787 "Stuffed"
6788 }
6789 fn field_display(&self, _: &str) -> Option<String> {
6790 None
6791 }
6792 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6793 unimplemented!()
6794 }
6795 }
6796 let cols = default_list_columns::<Stuffed>();
6797 assert_eq!(cols.len(), 5, "cap must hold at 5");
6798 assert_eq!(cols, vec!["id", "name", "status", "state", "priority"]);
6800 }
6801
6802 #[test]
6803 fn default_list_columns_rule_3_first_name_like_wins() {
6804 struct TwoNames;
6810 impl crate::orm::Model for TwoNames {
6811 const TABLE: &'static str = "two_names";
6812 const COLUMNS: &'static [&'static str] = &["id", "full_name", "email"];
6813 const INSERT_COLUMNS: &'static [&'static str] = &[];
6814 fn id(&self) -> i64 {
6815 0
6816 }
6817 fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6818 unimplemented!()
6819 }
6820 fn insert_values(&self) -> Vec<crate::orm::Value> {
6821 Vec::new()
6822 }
6823 }
6824 impl AdminModel for TwoNames {
6825 const ADMIN_NAME: &'static str = "two_names";
6826 const DISPLAY_NAME: &'static str = "TwoNames";
6827 const FIELDS: &'static [AdminField] = &[
6828 AdminField {
6829 name: "id",
6830 ty: FieldType::I64,
6831 editable: false,
6832 nullable: false,
6833 relation: None,
6834 },
6835 AdminField {
6836 name: "full_name",
6837 ty: FieldType::String,
6838 editable: true,
6839 nullable: false,
6840 relation: None,
6841 },
6842 AdminField {
6843 name: "email",
6844 ty: FieldType::String,
6845 editable: true,
6846 nullable: false,
6847 relation: None,
6848 },
6849 ];
6850 fn singular_name() -> &'static str {
6851 "TwoName"
6852 }
6853 fn field_display(&self, _: &str) -> Option<String> {
6854 None
6855 }
6856 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6857 unimplemented!()
6858 }
6859 }
6860 let cols = default_list_columns::<TwoNames>();
6861 assert_eq!(cols, vec!["id", "full_name"]);
6868 }
6869}