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 register_generated(&mut reg, build_orders_config());
484 reg
485 });
486
487 let index_db = db.clone();
492 let index_registry = admin_new_registry.clone();
493 let index_entries = entries.clone();
494 router = router.get("/admin", move |req, _params| {
495 let db = index_db.clone();
496 let registry = index_registry.clone();
497 let legacy_entries = index_entries.clone();
498 async move {
499 if let Err(resp) = admin_guard(req.ctx()) {
500 return Ok(resp);
501 }
502 let identity = crate::auth::identity(req.ctx()).cloned();
506 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
507 let html = crate::admin::layout::dashboard_render(
508 &db,
509 ®istry,
510 legacy_entries.as_slice(),
511 identity.as_ref(),
512 csrf.as_deref(),
513 )
514 .await;
515 Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
516 }
517 });
518 let login_db = db.clone();
525 router = router.post("/admin/login", move |req, _params| {
526 let db = login_db.clone();
527 async move { handle_login(req, &db).await }
528 });
529 router = router.get("/admin/login", |_req, _params| async move {
537 Ok::<Response, Error>(login_page(200, None, None))
538 });
539 let logout_db = db.clone();
540 router = router.post("/admin/logout", move |req, _params| {
541 let db = logout_db.clone();
542 async move { handle_logout(req, &db).await }
543 });
544 router = router.get("/admin/logout", move |req, _params| async move {
545 let signed_in = req.ctx().get::<crate::auth::Identity>().is_some();
546 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
547 Ok::<Response, Error>(logout_confirmation_response(signed_in, csrf.as_deref()))
548 });
549
550 let pw_get_entries = entries.clone();
553 let pw_get_db = db.clone();
554 let pw_get_registry = admin_new_registry.clone();
555 router = router.get("/admin/password_change", move |req, _params| {
556 let legacy_entries = pw_get_entries.clone();
557 let db = pw_get_db.clone();
558 let registry = pw_get_registry.clone();
559 async move {
560 if let Err(resp) = admin_guard(req.ctx()) {
561 return Ok(resp);
562 }
563 let identity = crate::auth::identity(req.ctx()).cloned();
564 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
565 let html = crate::admin::layout::password_change_render(
566 &db,
567 ®istry,
568 &legacy_entries,
569 identity.as_ref(),
570 csrf.as_deref(),
571 None,
572 )
573 .await;
574 Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
575 }
576 });
577 let pw_post_entries = entries.clone();
578 let pw_post_db = db.clone();
579 let pw_post_registry = admin_new_registry.clone();
580 router = router.post("/admin/password_change", move |req, _params| {
581 let legacy_entries = pw_post_entries.clone();
582 let db = pw_post_db.clone();
583 let registry = pw_post_registry.clone();
584 async move {
585 if let Err(resp) = admin_guard(req.ctx()) {
586 return Ok(resp);
587 }
588 handle_password_change_post(req, &db, ®istry, &legacy_entries).await
589 }
590 });
591 let pw_done_entries = entries.clone();
592 let pw_done_db = db.clone();
593 let pw_done_registry = admin_new_registry.clone();
594 router = router.get("/admin/password_change/done", move |req, _params| {
595 let legacy_entries = pw_done_entries.clone();
596 let db = pw_done_db.clone();
597 let registry = pw_done_registry.clone();
598 async move {
599 if let Err(resp) = admin_guard(req.ctx()) {
600 return Ok(resp);
601 }
602 let identity = crate::auth::identity(req.ctx()).cloned();
603 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
604 let html = crate::admin::layout::password_change_done_render(
605 &db,
606 ®istry,
607 &legacy_entries,
608 identity.as_ref(),
609 csrf.as_deref(),
610 )
611 .await;
612 Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
613 }
614 });
615
616 let profile_entries = entries.clone();
618 let profile_db = db.clone();
619 let profile_registry = admin_new_registry.clone();
620 router = router.get("/admin/profile", move |req, _params| {
621 let legacy_entries = profile_entries.clone();
622 let db = profile_db.clone();
623 let registry = profile_registry.clone();
624 async move {
625 if let Err(resp) = admin_guard(req.ctx()) {
626 return Ok(resp);
627 }
628 let identity = crate::auth::identity(req.ctx()).cloned();
629 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
630 let user = match identity.as_ref() {
631 Some(id) => crate::auth::user::find_by_id(&db, id.user_id).await?,
632 None => None,
633 };
634 let html = crate::admin::layout::profile_render(
635 &db,
636 ®istry,
637 &legacy_entries,
638 identity.as_ref(),
639 user.as_ref(),
640 csrf.as_deref(),
641 )
642 .await;
643 Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
644 }
645 });
646
647 let actions_entries = entries.clone();
649 let actions_db = db.clone();
650 let actions_registry = admin_new_registry.clone();
651 router = router.get("/admin/actions", move |req, _params| {
652 let legacy_entries = actions_entries.clone();
653 let db = actions_db.clone();
654 let registry = actions_registry.clone();
655 async move {
656 if let Err(resp) = admin_guard(req.ctx()) {
657 return Ok(resp);
658 }
659 let query = req.query();
660 let model_filter = query
661 .get("model")
662 .map(str::trim)
663 .filter(|s| !s.is_empty())
664 .map(String::from);
665 let action_filter = query
666 .get("action")
667 .map(str::trim)
668 .filter(|s| !s.is_empty())
669 .map(String::from);
670 let actions =
671 audit::recent(&db, 200, model_filter.as_deref(), action_filter.as_deref())
672 .await?;
673 let identity = crate::auth::identity(req.ctx()).cloned();
674 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
675 let html = crate::admin::layout::actions_render(
676 &db,
677 ®istry,
678 &legacy_entries,
679 identity.as_ref(),
680 csrf.as_deref(),
681 &actions,
682 model_filter.as_deref(),
683 action_filter.as_deref(),
684 )
685 .await;
686 Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
687 }
688 });
689
690 let sugg_get_entries = entries.clone();
694 let sugg_get_db = db.clone();
695 let sugg_get_registry = admin_new_registry.clone();
696 router = router.get("/admin/suggestions/:admin/:field", move |req, params| {
697 let legacy_entries = sugg_get_entries.clone();
698 let db = sugg_get_db.clone();
699 let registry = sugg_get_registry.clone();
700 async move {
701 if let Err(resp) = admin_guard(req.ctx()) {
702 return Ok(resp);
703 }
704 let admin_name = params.get("admin").unwrap_or("").to_string();
705 let field = params.get("field").unwrap_or("").to_string();
706 let identity = crate::auth::identity(req.ctx()).cloned();
707 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
708 Ok::<Response, Error>(
709 suggestion_review_response(
710 &db,
711 ®istry,
712 &legacy_entries,
713 identity.as_ref(),
714 csrf.as_deref(),
715 &admin_name,
716 &field,
717 None,
718 )
719 .await,
720 )
721 }
722 });
723 let sugg_post_entries = entries.clone();
724 let sugg_post_db = db.clone();
725 let sugg_post_registry = admin_new_registry.clone();
726 router = router.post("/admin/suggestions/:admin/:field", move |req, params| {
727 let legacy_entries = sugg_post_entries.clone();
728 let db = sugg_post_db.clone();
729 let registry = sugg_post_registry.clone();
730 async move {
731 if let Err(resp) = admin_guard(req.ctx()) {
732 return Ok(resp);
733 }
734 let admin_name = params.get("admin").unwrap_or("").to_string();
735 let field = params.get("field").unwrap_or("").to_string();
736 let (_, body, ctx) = req.into_parts();
737 let form = read_form_from_parts(body).await?;
738 require_csrf(&ctx, &form)?;
739 let identity = crate::auth::identity(&ctx).cloned();
740 let csrf = ctx_csrf(&ctx).map(str::to_string);
741 Ok::<Response, Error>(
742 suggestion_apply_response(
743 &db,
744 ®istry,
745 &legacy_entries,
746 identity.as_ref(),
747 csrf.as_deref(),
748 &admin_name,
749 &field,
750 )
751 .await,
752 )
753 }
754 });
755
756 router = router.post("/admin/schema/reload", move |req, _params| async move {
759 if let Err(resp) = admin_guard(req.ctx()) {
760 return Ok(resp);
761 }
762 let (_, body, ctx) = req.into_parts();
763 let form = read_form_from_parts(body).await?;
764 require_csrf(&ctx, &form)?;
765 let redirect_url = match schema_cache::refresh() {
769 Ok(_) => "/admin?schema_reload=ok",
770 Err(_) => "/admin?schema_reload=err",
771 };
772 Ok::<Response, Error>(with_admin_headers(redirect(redirect_url)))
773 });
774
775 {
785 let db = db.clone();
786 let registry = admin_new_registry.clone();
787 let model_entries = entries.clone();
788 router =
789 router.get("/admin/:model", move |req, params| {
790 let db = db.clone();
791 let registry = registry.clone();
792 let legacy_entries = model_entries.clone();
793 async move {
794 admin_model_index_get(&db, ®istry, &legacy_entries, req, params).await
795 }
796 });
797 }
798
799 {
806 let db = db.clone();
807 let registry = admin_new_registry.clone();
808 let form_new_entries = entries.clone();
809 router = router.get("/admin/:model/new", move |req, params| {
810 let db = db.clone();
811 let registry = registry.clone();
812 let legacy_entries = form_new_entries.clone();
813 async move {
814 admin_model_form_get(&db, ®istry, &legacy_entries, req, params, None).await
815 }
816 });
817 }
818 {
819 let db = db.clone();
820 let registry = admin_new_registry.clone();
821 let form_edit_entries = entries.clone();
822 router = router.get("/admin/:model/:id/edit", move |req, params| {
823 let db = db.clone();
824 let registry = registry.clone();
825 let legacy_entries = form_edit_entries.clone();
826 async move {
827 let id = params.get("id").map(str::to_string);
828 admin_model_form_get(
829 &db,
830 ®istry,
831 &legacy_entries,
832 req,
833 params,
834 id.as_deref(),
835 )
836 .await
837 }
838 });
839 }
840
841 {
843 let db = db.clone();
844 let registry = admin_new_registry.clone();
845 let create_entries = entries.clone();
846 router = router.post("/admin/:model/new", move |req, params| {
847 let db = db.clone();
848 let registry = registry.clone();
849 let legacy_entries = create_entries.clone();
850 async move {
851 admin_model_create_post(&db, ®istry, &legacy_entries, req, params).await
852 }
853 });
854 }
855 {
856 let db = db.clone();
857 let registry = admin_new_registry.clone();
858 let update_entries = entries.clone();
859 router = router.post("/admin/:model/:id/edit", move |req, params| {
860 let db = db.clone();
861 let registry = registry.clone();
862 let legacy_entries = update_entries.clone();
863 async move {
864 admin_model_update_post(&db, ®istry, &legacy_entries, req, params).await
865 }
866 });
867 }
868 {
869 let db = db.clone();
870 let registry = admin_new_registry.clone();
871 let delete_entries = entries.clone();
872 router = router.post("/admin/:model/:id/delete", move |req, params| {
873 let db = db.clone();
874 let registry = registry.clone();
875 let legacy_entries = delete_entries.clone();
876 async move {
877 admin_model_delete_post(&db, ®istry, &legacy_entries, req, params).await
878 }
879 });
880 }
881
882 for registrar in self.registrars {
883 router = registrar(router, db, entries.clone());
884 }
885 router
886 }
887}
888
889impl Default for Admin {
890 fn default() -> Self {
891 Self::new()
892 }
893}
894
895pub fn register<T>(router: Router, db: &Db) -> Router
897where
898 T: AdminModel + Model,
899{
900 Admin::new().model::<T>().register(router, db)
901}
902
903fn mount_model<T>(mut router: Router, db: &Db, entries: Arc<Vec<AdminEntry>>) -> Router
904where
905 T: AdminModel + Model,
906{
907 let base = format!("/admin/{}", T::ADMIN_NAME);
908 let create_path = format!("{base}/create");
909 let edit_path = format!("{base}/:id/edit");
910 let delete_path = format!("{base}/:id/delete");
911 let history_path = format!("{base}/:id/history");
912 let bulk_path = format!("{base}/bulk_action");
913
914 let list_db = db.clone();
916 let list_entries = entries.clone();
917 router = router.get(&base, move |req, _params| {
918 let db = list_db.clone();
919 let entries = list_entries.clone();
920 async move {
921 if let Err(resp) = admin_guard(req.ctx()) {
922 return Ok(resp);
923 }
924 let query = req.query();
925 let q = query
926 .get("q")
927 .map(str::trim)
928 .filter(|s| !s.is_empty())
929 .map(String::from);
930 let status = query
931 .get("status")
932 .map(str::trim)
933 .filter(|s| !s.is_empty())
934 .map(String::from);
935 let priority = query
936 .get("priority")
937 .map(str::trim)
938 .filter(|s| !s.is_empty())
939 .map(String::from);
940 let sort = query
944 .get("sort")
945 .map(str::trim)
946 .filter(|s| !s.is_empty())
947 .filter(|s| SORT_OPTIONS.iter().any(|(v, _)| *v == *s))
948 .map(String::from);
949
950 let visible_columns: Vec<&'static str> = default_list_columns::<T>();
954
955 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
956 let all_items = T::all(&db).await?;
957 let total = all_items.len();
958
959 let status_options = distinct_values::<T>(&all_items, "status");
963 let priority_options = distinct_values::<T>(&all_items, "priority");
964
965 let registry = current_registry();
970 let relation_filter_states = if registry.is_empty() {
971 Vec::new()
972 } else {
973 build_relation_filters::<T>(&db, ®istry, &query).await
974 };
975
976 let mut filtered: Vec<&T> = all_items
980 .iter()
981 .filter(|item| {
982 if let Some(qs) = &q {
983 if !matches_query::<T>(item, qs) {
984 return false;
985 }
986 }
987 if let Some(s) = &status {
988 let v = item.field_display("status").unwrap_or_default();
989 if &v != s {
990 return false;
991 }
992 }
993 for rel in &relation_filter_states {
998 if let Some(wanted) = rel.current_value {
999 let actual = item
1000 .field_display(&rel.field_name)
1001 .and_then(|s| s.parse::<i64>().ok());
1002 if actual != Some(wanted) {
1003 return false;
1004 }
1005 }
1006 }
1007 if let Some(p) = &priority {
1008 let v = item.field_display("priority").unwrap_or_default();
1009 if &v != p {
1010 return false;
1011 }
1012 }
1013 true
1014 })
1015 .collect();
1016
1017 match sort.as_deref() {
1021 Some("oldest") | Some("id_asc") => filtered.sort_by_key(|i| i.id()),
1022 Some("id_desc") => filtered.sort_by_key(|i| std::cmp::Reverse(i.id())),
1023 Some("newest") | None => filtered.sort_by_key(|i| std::cmp::Reverse(i.id())),
1024 _ => {}
1025 }
1026
1027 let filters = ListFilters {
1028 q: q.as_deref(),
1029 status: status.as_deref(),
1030 status_options: &status_options,
1031 priority: priority.as_deref(),
1032 priority_options: &priority_options,
1033 sort: sort.as_deref(),
1034 relation_filters: &relation_filter_states,
1035 visible_columns: &visible_columns,
1036 };
1037
1038 let fk_labels = if registry.is_empty() {
1044 FkLabels::new()
1045 } else {
1046 fetch_fk_labels::<T>(&db, &filtered, ®istry).await
1047 };
1048 let cell_ctx = CellCtx {
1049 registry: ®istry,
1050 fk_labels: &fk_labels,
1051 };
1052 Ok::<Response, Error>(list_response::<T>(
1053 shell, &filtered, total, filters, &cell_ctx,
1054 ))
1055 }
1056 });
1057
1058 let create_entries = entries.clone();
1060 let create_form_db = db.clone();
1061 router = router.get(&create_path, move |req, _params| {
1062 let entries = create_entries.clone();
1063 let db = create_form_db.clone();
1064 async move {
1065 if let Err(resp) = admin_guard(req.ctx()) {
1066 return Ok(resp);
1067 }
1068 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1069 let cell_ctx = CellCtx::empty();
1072 let inverse_counts = std::collections::HashMap::new();
1073 let registry = current_registry();
1074 let form_options = if registry.is_empty() {
1075 FormRelationOptions::new()
1076 } else {
1077 fetch_form_relation_options::<T>(&db, ®istry).await
1078 };
1079 Ok::<Response, Error>(form_response::<T>(
1080 shell,
1081 FormMode::Create,
1082 &cell_ctx,
1083 &inverse_counts,
1084 &form_options,
1085 ))
1086 }
1087 });
1088
1089 let create_db = db.clone();
1090 router = router.post(&create_path, move |req, _params| {
1091 let db = create_db.clone();
1092 async move {
1093 if let Err(resp) = admin_guard(req.ctx()) {
1094 return Ok(resp);
1095 }
1096 let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1099 let (_, body, ctx) = req.into_parts();
1100 let form = read_form_from_parts(body).await?;
1101 require_csrf(&ctx, &form)?;
1102 let user_id = ctx
1103 .get::<crate::auth::Identity>()
1104 .map(|i| i.user_id)
1105 .unwrap_or(0);
1106
1107 let item = T::from_form(&form, None)?;
1108 let primary = primary_string_value::<T>(&item);
1109 let new_id = item.create(&db).await?;
1110
1111 audit::record(
1112 &db,
1113 audit::LogEntry {
1114 user_id,
1115 action_type: audit::ActionType::Create,
1116 model_name: T::ADMIN_NAME,
1117 object_id: new_id,
1118 ip_address: peer_ip.as_deref(),
1119 summary: audit_summary(
1120 audit::ActionType::Create,
1121 T::singular_name(),
1122 new_id,
1123 &primary,
1124 ),
1125 },
1126 )
1127 .await?;
1128
1129 Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1130 "/admin/{}",
1131 T::ADMIN_NAME
1132 ))))
1133 }
1134 });
1135
1136 let edit_db = db.clone();
1138 let edit_entries = entries.clone();
1139 router = router.get(&edit_path, move |req, params| {
1140 let db = edit_db.clone();
1141 let entries = edit_entries.clone();
1142 async move {
1143 if let Err(resp) = admin_guard(req.ctx()) {
1144 return Ok(resp);
1145 }
1146 let id = parse_id_param(¶ms)?;
1147 let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
1148 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1149 let registry = current_registry();
1153 let items_ref: Vec<&T> = vec![&item];
1154 let fk_labels = if registry.is_empty() {
1155 FkLabels::new()
1156 } else {
1157 fetch_fk_labels::<T>(&db, &items_ref, ®istry).await
1158 };
1159 let inverse_counts = if registry.is_empty() {
1160 std::collections::HashMap::new()
1161 } else {
1162 fetch_inverse_counts(&db, T::singular_name(), id, ®istry).await
1163 };
1164 let cell_ctx = CellCtx {
1165 registry: ®istry,
1166 fk_labels: &fk_labels,
1167 };
1168 let form_options = if registry.is_empty() {
1169 FormRelationOptions::new()
1170 } else {
1171 fetch_form_relation_options::<T>(&db, ®istry).await
1172 };
1173 Ok::<Response, Error>(form_response::<T>(
1174 shell,
1175 FormMode::Edit { id, item: &item },
1176 &cell_ctx,
1177 &inverse_counts,
1178 &form_options,
1179 ))
1180 }
1181 });
1182
1183 let update_db = db.clone();
1184 router = router.post(&edit_path, move |req, params| {
1185 let db = update_db.clone();
1186 async move {
1187 if let Err(resp) = admin_guard(req.ctx()) {
1188 return Ok(resp);
1189 }
1190 let id = parse_id_param(¶ms)?;
1191 let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1192 let (_, body, ctx) = req.into_parts();
1193 let form = read_form_from_parts(body).await?;
1194 require_csrf(&ctx, &form)?;
1195 let user_id = ctx
1196 .get::<crate::auth::Identity>()
1197 .map(|i| i.user_id)
1198 .unwrap_or(0);
1199
1200 let item = T::from_form(&form, Some(id))?;
1201 let primary = primary_string_value::<T>(&item);
1202 item.update(&db).await?;
1203
1204 audit::record(
1205 &db,
1206 audit::LogEntry {
1207 user_id,
1208 action_type: audit::ActionType::Update,
1209 model_name: T::ADMIN_NAME,
1210 object_id: id,
1211 ip_address: peer_ip.as_deref(),
1212 summary: audit_summary(
1213 audit::ActionType::Update,
1214 T::singular_name(),
1215 id,
1216 &primary,
1217 ),
1218 },
1219 )
1220 .await?;
1221
1222 Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1223 "/admin/{}",
1224 T::ADMIN_NAME
1225 ))))
1226 }
1227 });
1228
1229 let delete_confirm_db = db.clone();
1231 let delete_confirm_entries = entries.clone();
1232 router = router.get(&delete_path, move |req, params| {
1233 let db = delete_confirm_db.clone();
1234 let entries = delete_confirm_entries.clone();
1235 async move {
1236 if let Err(resp) = admin_guard(req.ctx()) {
1237 return Ok(resp);
1238 }
1239 let id = parse_id_param(¶ms)?;
1240 let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
1241 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1242 Ok::<Response, Error>(delete_confirmation_response::<T>(shell, id, &item))
1243 }
1244 });
1245
1246 let delete_db = db.clone();
1247 let delete_entries = entries.clone();
1248 router = router.post(&delete_path, move |req, params| {
1249 let db = delete_db.clone();
1250 let entries = delete_entries.clone();
1251 async move {
1252 if let Err(resp) = admin_guard(req.ctx()) {
1253 return Ok(resp);
1254 }
1255 let id = parse_id_param(¶ms)?;
1256 let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1257 let (_, body, ctx) = req.into_parts();
1258 let form = read_form_from_parts(body).await?;
1259 require_csrf(&ctx, &form)?;
1260 let user_id = ctx
1261 .get::<crate::auth::Identity>()
1262 .map(|i| i.user_id)
1263 .unwrap_or(0);
1264
1265 let primary = match T::find(&db, id).await? {
1268 Some(item) => primary_string_value::<T>(&item),
1269 None => String::new(),
1270 };
1271
1272 let registry = current_registry();
1277 if !registry.is_empty() {
1278 let counts = fetch_inverse_counts(&db, T::singular_name(), id, ®istry).await;
1279 let blockers: Vec<(&relations::InverseRelation, i64)> = registry
1280 .has_many(T::singular_name())
1281 .iter()
1282 .filter_map(|inv| {
1283 let key = format!("{}.{}", inv.source_model, inv.source_field);
1284 counts
1285 .get(&key)
1286 .copied()
1287 .filter(|n| *n > 0)
1288 .map(|n| (inv, n))
1289 })
1290 .collect();
1291 if !blockers.is_empty() {
1292 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
1293 return Ok::<Response, Error>(render_delete_blocked_page::<T>(
1294 &shell, id, &primary, &blockers,
1295 ));
1296 }
1297 }
1298
1299 if let Err(e) = T::delete(&db, id).await {
1305 if is_foreign_key_violation(&e) {
1306 let registry = current_registry();
1307 let counts = fetch_inverse_counts(&db, T::singular_name(), id, ®istry).await;
1308 let blockers: Vec<(&relations::InverseRelation, i64)> = registry
1309 .has_many(T::singular_name())
1310 .iter()
1311 .filter_map(|inv| {
1312 let key = format!("{}.{}", inv.source_model, inv.source_field);
1313 counts
1314 .get(&key)
1315 .copied()
1316 .filter(|n| *n > 0)
1317 .map(|n| (inv, n))
1318 })
1319 .collect();
1320 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
1321 return Ok::<Response, Error>(render_delete_blocked_page::<T>(
1322 &shell, id, &primary, &blockers,
1323 ));
1324 }
1325 return Err(e);
1326 }
1327
1328 audit::record(
1329 &db,
1330 audit::LogEntry {
1331 user_id,
1332 action_type: audit::ActionType::Delete,
1333 model_name: T::ADMIN_NAME,
1334 object_id: id,
1335 ip_address: peer_ip.as_deref(),
1336 summary: audit_summary(
1337 audit::ActionType::Delete,
1338 T::singular_name(),
1339 id,
1340 &primary,
1341 ),
1342 },
1343 )
1344 .await?;
1345
1346 Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1347 "/admin/{}",
1348 T::ADMIN_NAME
1349 ))))
1350 }
1351 });
1352
1353 let history_db = db.clone();
1357 let history_entries = entries.clone();
1358 router = router.get(&history_path, move |req, params| {
1359 let db = history_db.clone();
1360 let entries = history_entries.clone();
1361 async move {
1362 if let Err(resp) = admin_guard(req.ctx()) {
1363 return Ok(resp);
1364 }
1365 let id = parse_id_param(¶ms)?;
1366 let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
1367 let actions = audit::for_object(&db, T::ADMIN_NAME, id).await?;
1368 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1369 Ok::<Response, Error>(object_history_response::<T>(shell, id, &item, &actions))
1370 }
1371 });
1372
1373 let bulk_db = db.clone();
1379 let bulk_entries = entries.clone();
1380 router = router.post(&bulk_path, move |req, _params| {
1381 let db = bulk_db.clone();
1382 let entries = bulk_entries.clone();
1383 async move {
1384 if let Err(resp) = admin_guard(req.ctx()) {
1385 return Ok(resp);
1386 }
1387 let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1388 let (_, body, ctx) = req.into_parts();
1389 let form = read_form_from_parts(body).await?;
1390 require_csrf(&ctx, &form)?;
1391 let user_id = ctx
1392 .get::<crate::auth::Identity>()
1393 .map(|i| i.user_id)
1394 .unwrap_or(0);
1395
1396 let action = form.get("action").unwrap_or("").trim().to_string();
1397 let selected_raw = form.get("_selected").unwrap_or("").to_string();
1398 let ids: Vec<i64> = selected_raw
1399 .split(',')
1400 .map(str::trim)
1401 .filter(|s| !s.is_empty())
1402 .filter_map(|s| s.parse::<i64>().ok())
1403 .collect();
1404 let confirmed = form.get("_confirm").map(|v| v == "yes").unwrap_or(false);
1405
1406 if ids.is_empty() || action.is_empty() {
1407 return Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1408 "/admin/{}",
1409 T::ADMIN_NAME
1410 ))));
1411 }
1412
1413 if action != "delete" {
1414 return Err(Error::BadRequest(
1415 format!("Unknown bulk action `{action}`",),
1416 ));
1417 }
1418
1419 if !confirmed {
1422 let mut items: Vec<(i64, String)> = Vec::with_capacity(ids.len());
1423 for id in &ids {
1424 if let Some(item) = T::find(&db, *id).await? {
1425 let primary = primary_string_value::<T>(&item);
1426 items.push((*id, primary));
1427 }
1428 }
1429 let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
1430 return Ok::<Response, Error>(bulk_delete_confirmation_response::<T>(
1431 &shell, &items,
1432 ));
1433 }
1434
1435 for id in &ids {
1438 let primary = match T::find(&db, *id).await? {
1439 Some(item) => primary_string_value::<T>(&item),
1440 None => continue, };
1442 T::delete(&db, *id).await?;
1443
1444 let mut summary =
1445 audit_summary(audit::ActionType::Delete, T::singular_name(), *id, &primary);
1446 summary.push_str(" (via bulk action)");
1447
1448 audit::record(
1449 &db,
1450 audit::LogEntry {
1451 user_id,
1452 action_type: audit::ActionType::Delete,
1453 model_name: T::ADMIN_NAME,
1454 object_id: *id,
1455 ip_address: peer_ip.as_deref(),
1456 summary,
1457 },
1458 )
1459 .await?;
1460 }
1461
1462 Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1463 "/admin/{}",
1464 T::ADMIN_NAME
1465 ))))
1466 }
1467 });
1468
1469 router
1470}
1471
1472fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
1473 params
1474 .get("id")
1475 .and_then(|s| s.parse::<i64>().ok())
1476 .ok_or_else(|| Error::BadRequest(String::from("invalid id")))
1477}
1478
1479fn primary_string_value<T: AdminModel>(item: &T) -> String {
1483 T::FIELDS
1484 .iter()
1485 .find(|f| f.editable && matches!(f.ty, FieldType::String))
1486 .and_then(|f| item.field_display(f.name))
1487 .filter(|s| !s.is_empty())
1488 .unwrap_or_default()
1489}
1490
1491fn audit_summary(action: audit::ActionType, singular: &str, id: i64, primary: &str) -> String {
1494 let verb = action.label();
1495 if primary.is_empty() {
1496 format!("{verb} {singular} #{id}")
1497 } else {
1498 format!("{verb} {singular} #{id}: {primary}")
1499 }
1500}
1501
1502pub const MAX_FORM_BODY_BYTES: usize = crate::http::MAX_REQUEST_BODY_BYTES;
1508
1509pub const CSRF_FIELD: &str = "_csrf";
1511
1512fn ctx_csrf(ctx: &crate::context::Context) -> Option<&str> {
1513 ctx.get::<crate::auth::CsrfToken>().map(|t| t.0.as_str())
1514}
1515
1516fn ctx_user_email(ctx: &crate::context::Context) -> Option<&str> {
1517 ctx.get::<crate::auth::Identity>().map(|i| i.email.as_str())
1518}
1519
1520fn csrf_input(csrf: Option<&str>) -> String {
1521 match csrf {
1522 Some(token) if !token.is_empty() => format!(
1523 r#"<input type="hidden" name="{name}" value="{value}">"#,
1524 name = CSRF_FIELD,
1525 value = escape_html(token),
1526 ),
1527 _ => String::new(),
1528 }
1529}
1530
1531fn require_csrf(ctx: &crate::context::Context, form: &FormData) -> Result<(), Error> {
1532 let expected = ctx
1533 .get::<crate::auth::CsrfToken>()
1534 .map(|t| t.0.as_str())
1535 .unwrap_or("");
1536 let provided = form.get(CSRF_FIELD).unwrap_or("");
1537 if !crate::auth::csrf::verify_token(expected, provided) {
1538 return Err(Error::Forbidden);
1539 }
1540 Ok(())
1541}
1542
1543fn with_admin_headers(mut resp: Response) -> Response {
1544 use hyper::header::HeaderValue;
1545 let h = resp.headers_mut();
1546 h.insert("x-frame-options", HeaderValue::from_static("DENY"));
1547 h.insert(
1548 "x-content-type-options",
1549 HeaderValue::from_static("nosniff"),
1550 );
1551 h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
1552 if crate::auth::in_production() {
1553 h.insert(
1554 "strict-transport-security",
1555 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1556 );
1557 }
1558 resp
1559}
1560
1561async fn read_form(req: Request) -> Result<FormData, Error> {
1562 let (_, body, _) = req.into_parts();
1563 read_form_from_parts(body).await
1564}
1565
1566async fn read_form_from_parts(body: hyper::body::Incoming) -> Result<FormData, Error> {
1567 let limited = http_body_util::Limited::new(body, MAX_FORM_BODY_BYTES);
1568 let collected = limited.collect().await.map_err(|e| {
1569 if e.downcast_ref::<http_body_util::LengthLimitError>()
1570 .is_some()
1571 {
1572 Error::PayloadTooLarge
1573 } else {
1574 Error::BadRequest(e.to_string())
1575 }
1576 })?;
1577 let bytes = collected.to_bytes();
1578 let body_str = std::str::from_utf8(&bytes).map_err(|e| Error::BadRequest(e.to_string()))?;
1579 Ok(FormData::parse(body_str))
1580}
1581
1582fn redirect(to: &str) -> Response {
1583 hyper::Response::builder()
1584 .status(303)
1585 .header("location", to)
1586 .body(Full::new(Bytes::new()))
1587 .expect("valid redirect")
1588}
1589
1590fn admin_css_response() -> Response {
1604 use hyper::header::HeaderValue;
1605 let body = ADMIN_CSS_BUNDLE.as_bytes();
1606 let etag = {
1611 let len = body.len();
1612 let head = u32::from_le_bytes([
1613 *body.first().unwrap_or(&0),
1614 *body.get(1).unwrap_or(&0),
1615 *body.get(2).unwrap_or(&0),
1616 *body.get(3).unwrap_or(&0),
1617 ]);
1618 let tail = u32::from_le_bytes([
1619 *body.get(len.saturating_sub(4)).unwrap_or(&0),
1620 *body.get(len.saturating_sub(3)).unwrap_or(&0),
1621 *body.get(len.saturating_sub(2)).unwrap_or(&0),
1622 *body.get(len.saturating_sub(1)).unwrap_or(&0),
1623 ]);
1624 format!("W/\"rio-{len}-{head:x}-{tail:x}\"")
1625 };
1626 let mut resp = hyper::Response::builder()
1627 .status(200)
1628 .header("content-type", "text/css; charset=utf-8")
1629 .header("cache-control", "no-cache, must-revalidate")
1630 .header("etag", etag)
1631 .body(Full::new(Bytes::from_static(ADMIN_CSS_BUNDLE.as_bytes())))
1632 .expect("valid css response");
1633 let h = resp.headers_mut();
1634 h.insert(
1637 "x-content-type-options",
1638 HeaderValue::from_static("nosniff"),
1639 );
1640 resp
1641}
1642
1643fn env_chip_html() -> String {
1653 if crate::auth::in_production() {
1654 r#"<span class="rio-env-chip is-prod">production</span>"#.to_string()
1655 } else {
1656 r#"<span class="rio-env-chip">development</span>"#.to_string()
1657 }
1658}
1659
1660fn bundled_asset_response(bytes: &'static [u8], content_type: &'static str) -> Response {
1666 use hyper::header::HeaderValue;
1667 let etag = {
1668 let len = bytes.len();
1669 let head = u32::from_le_bytes([
1670 *bytes.first().unwrap_or(&0),
1671 *bytes.get(1).unwrap_or(&0),
1672 *bytes.get(2).unwrap_or(&0),
1673 *bytes.get(3).unwrap_or(&0),
1674 ]);
1675 let tail = u32::from_le_bytes([
1676 *bytes.get(len.saturating_sub(4)).unwrap_or(&0),
1677 *bytes.get(len.saturating_sub(3)).unwrap_or(&0),
1678 *bytes.get(len.saturating_sub(2)).unwrap_or(&0),
1679 *bytes.get(len.saturating_sub(1)).unwrap_or(&0),
1680 ]);
1681 format!("W/\"rio-{len}-{head:x}-{tail:x}\"")
1682 };
1683 let mut resp = hyper::Response::builder()
1684 .status(200)
1685 .header("content-type", content_type)
1686 .header("cache-control", "public, max-age=3600")
1687 .header("etag", etag)
1688 .body(Full::new(Bytes::from_static(bytes)))
1689 .expect("valid static asset response");
1690 resp.headers_mut().insert(
1691 "x-content-type-options",
1692 HeaderValue::from_static("nosniff"),
1693 );
1694 resp
1695}
1696
1697fn admin_favicon_response() -> Response {
1698 use hyper::header::HeaderValue;
1699 let mut resp = hyper::Response::builder()
1700 .status(200)
1701 .header("content-type", "image/svg+xml")
1702 .header("cache-control", "public, max-age=86400")
1703 .body(Full::new(Bytes::from_static(ADMIN_FAVICON_SVG.as_bytes())))
1704 .expect("valid favicon response");
1705 resp.headers_mut().insert(
1706 "x-content-type-options",
1707 HeaderValue::from_static("nosniff"),
1708 );
1709 resp
1710}
1711
1712struct Shell<'a> {
1721 entries: &'a [AdminEntry],
1722 active: Option<&'a str>,
1723 user_email: Option<&'a str>,
1724 csrf: Option<&'a str>,
1725}
1726
1727impl<'a> Shell<'a> {
1728 fn from_ctx(
1729 entries: &'a [AdminEntry],
1730 active: Option<&'a str>,
1731 ctx: &'a crate::context::Context,
1732 ) -> Self {
1733 Self {
1734 entries,
1735 active,
1736 user_email: ctx_user_email(ctx),
1737 csrf: ctx_csrf(ctx),
1738 }
1739 }
1740}
1741
1742type Crumb<'a> = (&'a str, Option<&'a str>);
1745
1746fn render_breadcrumbs(crumbs: &[Crumb<'_>]) -> String {
1747 if crumbs.is_empty() {
1748 return String::new();
1749 }
1750 let sep = format!(
1751 r#"<span class="rio-crumb-sep">{}</span>"#,
1752 icon_chevron_right()
1753 );
1754 let mut out = String::from(r#"<nav class="rio-breadcrumbs" aria-label="Breadcrumb">"#);
1755 for (i, (label, href)) in crumbs.iter().enumerate() {
1756 let is_last = i == crumbs.len() - 1;
1757 if i > 0 {
1758 out.push_str(&sep);
1759 }
1760 match (is_last, href) {
1761 (true, _) => {
1762 out.push_str(&format!(
1763 r#"<span class="rio-crumb-current" aria-current="page">{}</span>"#,
1764 escape_html(label),
1765 ));
1766 }
1767 (false, Some(h)) => {
1768 out.push_str(&format!(
1769 r#"<a href="{}">{}</a>"#,
1770 escape_html(h),
1771 escape_html(label),
1772 ));
1773 }
1774 (false, None) => {
1775 out.push_str(&escape_html(label));
1776 }
1777 }
1778 }
1779 out.push_str("</nav>");
1780 out
1781}
1782
1783const NAV_ACTIONS: &str = "__actions";
1787
1788fn humanise_model_label(name: &str) -> String {
1804 if name == "Staffs" {
1807 return "Staff".to_string();
1808 }
1809 if name == "Diagnosis" {
1810 return "Diagnoses".to_string();
1811 }
1812 let mut out = String::with_capacity(name.len() + 4);
1814 for (i, ch) in name.chars().enumerate() {
1815 if i > 0 && ch.is_ascii_uppercase() {
1816 out.push(' ');
1817 }
1818 out.push(ch);
1819 }
1820 out
1821}
1822
1823fn render_sidebar(shell: &Shell<'_>) -> String {
1824 let design = design::Design::global();
1825 let user_facing: Vec<&AdminEntry> = shell.entries.iter().filter(|e| !e.core).collect();
1826
1827 let mut models_html = String::new();
1828 if !user_facing.is_empty() {
1829 models_html.push_str(r#"<div class="rio-nav">"#);
1830 models_html.push_str(r#"<div class="rio-nav-section">Models</div>"#);
1831 for e in &user_facing {
1832 let active_cls = if shell.active == Some(e.admin_name) {
1833 "rio-nav-link is-active"
1834 } else {
1835 "rio-nav-link"
1836 };
1837 models_html.push_str(&format!(
1838 r#"<a class="{cls}" href="/admin/{name}">{icon}<span>{label}</span></a>"#,
1839 cls = active_cls,
1840 name = escape_html(e.admin_name),
1841 icon = icon_layers(),
1842 label = escape_html(&humanise_model_label(e.display_name)),
1843 ));
1844 }
1845 models_html.push_str("</div>");
1846 }
1847
1848 let dashboard_active = if shell.active.is_none() {
1853 "rio-nav-link is-active"
1854 } else {
1855 "rio-nav-link"
1856 };
1857 let actions_active = if shell.active == Some(NAV_ACTIONS) {
1858 "rio-nav-link is-active"
1859 } else {
1860 "rio-nav-link"
1861 };
1862
1863 let logout_form = if shell.csrf.is_some() {
1864 format!(
1865 r#"<form class="rio-sidebar-logout" method="post" action="/admin/logout">
1866{csrf}
1867<button type="submit">{icon}<span>Sign out</span></button>
1868</form>"#,
1869 csrf = csrf_input(shell.csrf),
1870 icon = icon_logout(),
1871 )
1872 } else {
1873 String::new()
1874 };
1875
1876 let email = shell.user_email.unwrap_or("");
1877 let avatar_initial = email
1878 .chars()
1879 .next()
1880 .map(|c| c.to_ascii_uppercase().to_string())
1881 .unwrap_or_else(|| String::from("·"));
1882
1883 let user_block = if shell.user_email.is_some() {
1884 format!(
1888 r#"<a class="rio-sidebar-user" href="/admin/profile" title="Your profile">
1889<span class="rio-avatar">{avatar}</span>
1890<span class="rio-user-email">{email}</span>
1891</a>"#,
1892 avatar = escape_html(&avatar_initial),
1893 email = escape_html(email),
1894 )
1895 } else {
1896 String::new()
1897 };
1898
1899 format!(
1900 r#"<aside class="rio-sidebar">
1901<div class="rio-sidebar-inner">
1902<a class="rio-brand" href="/admin">
1903<span class="rio-brand-mark">{logo}</span>
1904<span class="rio-brand-meta">
1905<span class="rio-brand-name">{project}</span>
1906<span class="rio-brand-label">Admin</span>
1907</span>
1908</a>
1909<nav class="rio-nav">
1910<a class="{dash}" href="/admin">{dash_icon}<span>Dashboard</span></a>
1911<a class="{actions}" href="/admin/actions">{actions_icon}<span>Recent actions</span></a>
1912</nav>
1913{models}
1914<div class="rio-sidebar-footer">
1915{user}
1916{logout}
1917</div>
1918</div>
1919</aside>"#,
1920 logo = escape_html(&design.logo_initial),
1921 project = escape_html(&design.project_name),
1922 dash = dashboard_active,
1923 dash_icon = icon_dashboard(),
1924 actions = actions_active,
1925 actions_icon = icon_activity(),
1926 models = models_html,
1927 user = user_block,
1928 logout = logout_form,
1929 )
1930}
1931
1932#[allow(clippy::too_many_arguments)]
1935fn render_shell_page(
1936 shell: &Shell<'_>,
1937 status: u16,
1938 document_title: &str,
1939 page_title: &str,
1940 page_subtitle: Option<&str>,
1941 breadcrumbs: &[Crumb<'_>],
1942 actions: &str,
1943 body: &str,
1944) -> Response {
1945 let design = design::Design::global();
1946
1947 let sidebar = render_sidebar(shell);
1948 let crumbs = render_breadcrumbs(breadcrumbs);
1949
1950 let env_chip = env_chip_html();
1951
1952 let topbar_actions = match shell.csrf {
1958 Some(csrf) => format!(
1959 r#"<div class="rio-topbar-actions">
1960{env}
1961<a class="rio-topbar-icon" href="/admin" title="Home" aria-label="Home">{home}</a>
1962<button class="rio-topbar-icon" type="button" title="Notifications" aria-label="Notifications">{bell}<span class="rio-topbar-dot"></span></button>
1963<button class="rio-topbar-icon" type="button" title="Messages" aria-label="Messages">{mail}</button>
1964<form class="rio-topbar-logout" method="post" action="/admin/logout">
1965<input type="hidden" name="_csrf" value="{csrf_val}">
1966<button type="submit" title="Sign out">{logout}<span>Logout</span></button>
1967</form>
1968</div>"#,
1969 env = env_chip,
1970 home = icon_home(),
1971 bell = icon_bell(),
1972 mail = icon_mail(),
1973 logout = icon_logout(),
1974 csrf_val = escape_html(csrf),
1975 ),
1976 None => format!(
1977 r#"<div class="rio-topbar-actions">{env}</div>"#,
1978 env = env_chip
1979 ),
1980 };
1981
1982 let subtitle_html = page_subtitle
1983 .map(|s| format!(r#"<p class="rio-page-subtitle">{}</p>"#, escape_html(s)))
1984 .unwrap_or_default();
1985
1986 let actions_block = if actions.is_empty() {
1987 String::new()
1988 } else {
1989 format!(r#"<div class="rio-page-actions">{actions}</div>"#)
1990 };
1991
1992 let theme_style = format!(
1993 "\n:root {{\n --rio-primary: {p};\n --rio-primary-hover: {ph};\n --rio-accent: {a};\n --rio-accent-hover: {ah};\n}}\n",
1994 p = escape_css_color(&design.primary_color),
1995 ph = escape_css_color(&design.primary_color),
1996 a = escape_css_color(&design.accent_color),
1997 ah = escape_css_color(&design.accent_color),
1998 );
1999
2000 let density_class = match design.density {
2001 design::Density::Comfortable => "",
2002 design::Density::Compact => " rio-density-compact",
2003 };
2004
2005 let body_html = format!(
2006 r#"<!doctype html>
2007<html lang="en">
2008<head>
2009<meta charset="utf-8">
2010<meta name="viewport" content="width=device-width, initial-scale=1">
2011<title>{doc_title} · {project}</title>
2012<link rel="stylesheet" href="/admin/assets/admin.css?v={css_ver}">
2013<link rel="icon" type="image/svg+xml" href="/admin/assets/favicon.svg">
2014<style>{theme}</style>
2015</head>
2016<body class="rio-body{density}">
2017<div class="rio-app">
2018{sidebar}
2019<main class="rio-main">
2020<div class="rio-container">
2021<header class="rio-topbar">
2022{crumbs}
2023{topbar_actions}
2024</header>
2025<div class="rio-page-header">
2026<div>
2027<h1 class="rio-page-title">{page_title}</h1>
2028{subtitle}
2029</div>
2030{actions}
2031</div>
2032{body}
2033</div>
2034</main>
2035</div>
2036<script>
2037// Admin Intelligence Layer (0.7.0) — minimal JS for PII toggle.
2038// Click a .rio-pii-toggle to reveal / hide the adjacent masked value.
2039document.addEventListener("click", function(e){{
2040 var btn = e.target.closest ? e.target.closest(".rio-pii-toggle") : null;
2041 if(!btn) return;
2042 // The masked <span> is the button's previous sibling by construction.
2043 var span = btn.previousElementSibling;
2044 if(!span || !span.classList.contains("rio-pii")) return;
2045 if(span.getAttribute("data-hidden") === "1"){{
2046 span.textContent = span.getAttribute("data-value") || "";
2047 span.setAttribute("data-hidden","0");
2048 btn.textContent = "hide";
2049 }} else {{
2050 span.textContent = span.getAttribute("data-mask") || "";
2051 span.setAttribute("data-hidden","1");
2052 btn.textContent = "show";
2053 }}
2054}});
2055</script>
2056</body>
2057</html>"#,
2058 doc_title = escape_html(document_title),
2059 project = escape_html(&design.project_name),
2060 theme = theme_style,
2061 density = density_class,
2062 sidebar = sidebar,
2063 crumbs = crumbs,
2064 topbar_actions = topbar_actions,
2065 page_title = escape_html(page_title),
2066 subtitle = subtitle_html,
2067 actions = actions_block,
2068 body = body,
2069 css_ver = ADMIN_CSS_VER,
2070 );
2071
2072 let resp = hyper::Response::builder()
2073 .status(status)
2074 .header("content-type", "text/html; charset=utf-8")
2075 .header(
2080 "cache-control",
2081 "no-store, no-cache, must-revalidate, max-age=0",
2082 )
2083 .header("pragma", "no-cache")
2084 .header("expires", "0")
2085 .body(Full::new(Bytes::from(body_html)))
2086 .expect("valid response");
2087 with_admin_headers(resp)
2088}
2089
2090fn escape_css_color(s: &str) -> &str {
2096 if s.contains([';', '{', '}', '<', '\\']) {
2097 "#0f172a"
2099 } else {
2100 s
2101 }
2102}
2103
2104pub fn register_generated(
2113 registry: &mut crate::admin::admin_form_bridge::AdminRegistry,
2114 cfg: crate::admin::admin_generator::AdminModelConfig,
2115) {
2116 let slug = cfg.slug;
2117 registry.register(slug, move || {
2118 crate::admin::admin_generator::from_config(cfg.clone())
2119 });
2120}
2121
2122pub async fn register_from_table(
2129 db: &Db,
2130 registry: &mut crate::admin::admin_form_bridge::AdminRegistry,
2131 table: &str,
2132) -> Result<(), Error> {
2133 let cfg = crate::admin::schema_introspect::generate_from_table(db, table).await?;
2134 register_generated(registry, cfg);
2135 Ok(())
2136}
2137
2138fn build_orders_config() -> crate::admin::admin_generator::AdminModelConfig {
2143 use crate::admin::admin_form_bridge::AdminUiField;
2144 use crate::admin::admin_generator::AdminModelConfig;
2145
2146 AdminModelConfig::new("orders", "Order")
2147 .table("admin_new_demo_orders")
2148 .primary_key("id")
2149 .fields(vec![
2150 AdminUiField::text("order_number", "Order #")
2151 .required(true)
2152 .filterable(true)
2153 .sortable(true),
2154 AdminUiField::email("customer_email", "Customer")
2155 .required(true)
2156 .filterable(true)
2157 .advanced_filter(true),
2158 AdminUiField::float("total_amount", "Total").sortable(true),
2159 AdminUiField::boolean("is_paid", "Paid")
2160 .filterable(true)
2161 .sortable(true),
2162 ])
2163 .searchable(vec!["order_number", "customer_email"])
2164 .status_field("is_paid")
2165 .ensure_sql(
2166 "CREATE TABLE IF NOT EXISTS admin_new_demo_orders (
2167 id INTEGER PRIMARY KEY AUTOINCREMENT,
2168 order_number TEXT,
2169 customer_email TEXT,
2170 total_amount TEXT,
2171 is_paid TEXT
2172 )",
2173 )
2174}
2175
2176fn empty_state_hint<T: AdminModel>(context: Option<&crate::ai::ContextConfig>) -> Option<String> {
2183 let ctx = context?;
2184 let schema = ctx.industry_schema()?;
2185 let model_has_convention = schema
2189 .required_fields
2190 .iter()
2191 .any(|f| T::FIELDS.iter().any(|af| af.name == f.as_str()));
2192 if !model_has_convention {
2193 return None;
2194 }
2195 let country_phrase = match ctx.country.as_deref() {
2196 Some(cc) if cc.eq_ignore_ascii_case("SE") => "In Sweden, ",
2197 Some(cc) if cc.eq_ignore_ascii_case("NO") => "In Norway, ",
2198 _ => "",
2199 };
2200 let industry = ctx.industry.as_deref().unwrap_or("");
2201 let singular_lower = T::singular_name().to_lowercase();
2202 let fields_list = schema.required_fields.join(", ");
2203 Some(format!(
2204 "{country}{industry} {singular}s usually include {fields}.",
2205 country = country_phrase,
2206 industry = industry,
2207 singular = singular_lower,
2208 fields = fields_list,
2209 ))
2210}
2211
2212struct ListFilters<'a> {
2220 q: Option<&'a str>,
2221 status: Option<&'a str>,
2222 status_options: &'a [String],
2223 priority: Option<&'a str>,
2224 priority_options: &'a [String],
2225 sort: Option<&'a str>,
2228 relation_filters: &'a [RelationFilterState],
2232 visible_columns: &'a [&'static str],
2236}
2237
2238struct RelationFilterState {
2241 field_name: String,
2243 label: String,
2245 current_value: Option<i64>,
2247 mode: RelationFilterMode,
2249}
2250
2251enum RelationFilterMode {
2252 Dropdown { options: Vec<(i64, String)> },
2256 Numeric { too_many: bool },
2260}
2261
2262impl ListFilters<'_> {
2263 fn is_active(&self) -> bool {
2264 self.q.is_some()
2265 || self.status.is_some()
2266 || self.priority.is_some()
2267 || self.sort.is_some()
2268 || self
2269 .relation_filters
2270 .iter()
2271 .any(|r| r.current_value.is_some())
2272 }
2273}
2274
2275const SORT_OPTIONS: &[(&str, &str)] = &[
2279 ("newest", "Newest first"),
2280 ("oldest", "Oldest first"),
2281 ("id_asc", "ID ↑"),
2282 ("id_desc", "ID ↓"),
2283];
2284
2285type FkLabels = std::collections::HashMap<String, std::collections::HashMap<i64, String>>;
2296
2297type FormRelationOptions = std::collections::HashMap<String, Vec<(i64, String)>>;
2310
2311struct CellCtx<'a> {
2315 registry: &'a relations::RelationRegistry,
2316 fk_labels: &'a FkLabels,
2317}
2318
2319impl CellCtx<'_> {
2320 fn empty() -> CellCtx<'static> {
2325 static EMPTY_REG: std::sync::OnceLock<relations::RelationRegistry> =
2326 std::sync::OnceLock::new();
2327 static EMPTY_LABELS: std::sync::OnceLock<FkLabels> = std::sync::OnceLock::new();
2328 CellCtx {
2329 registry: EMPTY_REG.get_or_init(relations::RelationRegistry::empty),
2330 fk_labels: EMPTY_LABELS.get_or_init(std::collections::HashMap::new),
2331 }
2332 }
2333}
2334
2335fn current_registry() -> relations::RelationRegistry {
2340 match schema_cache::snapshot() {
2341 Some(c) => relations::RelationRegistry::from_schema(&c.schema),
2342 None => relations::RelationRegistry::empty(),
2343 }
2344}
2345
2346type InverseCounts = std::collections::HashMap<String, i64>;
2367
2368async fn fetch_inverse_counts(
2378 db: &Db,
2379 target_model: &str,
2380 target_id: i64,
2381 registry: &relations::RelationRegistry,
2382) -> InverseCounts {
2383 use sqlx::Row;
2384 let mut out: InverseCounts = std::collections::HashMap::new();
2385 for inv in registry.has_many(target_model) {
2386 let sql = format!(
2387 "SELECT COUNT(*) AS rio_count FROM \"{table}\" WHERE \"{col}\" = ?",
2388 table = inv.source_table,
2389 col = inv.source_field,
2390 );
2391 let row = match sqlx::query(&sql).bind(target_id).fetch_one(db.pool()).await {
2392 Ok(r) => r,
2393 Err(_) => continue,
2394 };
2395 let count: i64 = row.try_get::<i64, _>("rio_count").unwrap_or_default();
2396 out.insert(format!("{}.{}", inv.source_model, inv.source_field), count);
2397 }
2398 out
2399}
2400
2401async fn build_relation_filters<T: AdminModel>(
2409 db: &Db,
2410 registry: &relations::RelationRegistry,
2411 query: &FormData,
2412) -> Vec<RelationFilterState> {
2413 use sqlx::Row;
2414 let mut out: Vec<RelationFilterState> = Vec::new();
2415 let cap = relations::RELATION_FILTER_DROPDOWN_CAP;
2416 for resolved in registry.belongs_to_of(T::singular_name()) {
2417 let current_value = query
2419 .get(&resolved.source_field)
2420 .and_then(|v| v.parse::<i64>().ok());
2421
2422 let mode = match &resolved.target_display_field {
2424 None => RelationFilterMode::Numeric { too_many: false },
2425 Some(display_col) => {
2426 let sql = format!(
2429 "SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" ORDER BY \"{col}\" ASC LIMIT {lim}",
2430 col = display_col,
2431 table = resolved.target_table,
2432 lim = cap + 1,
2433 );
2434 let rows = match sqlx::query(&sql).fetch_all(db.pool()).await {
2435 Ok(r) => r,
2436 Err(_) => {
2437 out.push(RelationFilterState {
2439 field_name: resolved.source_field.clone(),
2440 label: resolved.target_model.clone(),
2441 current_value,
2442 mode: RelationFilterMode::Numeric { too_many: false },
2443 });
2444 continue;
2445 }
2446 };
2447 if rows.len() > cap {
2448 RelationFilterMode::Numeric { too_many: true }
2449 } else {
2450 let options: Vec<(i64, String)> = rows
2451 .into_iter()
2452 .map(|row| {
2453 let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
2454 let label: String = row
2455 .try_get::<String, _>("rio_label")
2456 .or_else(|_| {
2457 row.try_get::<i64, _>("rio_label").map(|n| n.to_string())
2458 })
2459 .or_else(|_| {
2460 row.try_get::<i32, _>("rio_label").map(|n| n.to_string())
2461 })
2462 .unwrap_or_default();
2463 (id, label)
2464 })
2465 .collect();
2466 RelationFilterMode::Dropdown { options }
2467 }
2468 }
2469 };
2470
2471 out.push(RelationFilterState {
2472 field_name: resolved.source_field.clone(),
2473 label: resolved.target_model.clone(),
2474 current_value,
2475 mode,
2476 });
2477 }
2478 out
2479}
2480
2481async fn fetch_form_relation_options<T: AdminModel>(
2487 db: &Db,
2488 registry: &relations::RelationRegistry,
2489) -> FormRelationOptions {
2490 use sqlx::Row;
2491 let mut out: FormRelationOptions = std::collections::HashMap::new();
2492 let cap = relations::RELATION_FILTER_DROPDOWN_CAP;
2493 for resolved in registry.belongs_to_of(T::singular_name()) {
2494 let Some(display_col) = &resolved.target_display_field else {
2495 continue;
2496 };
2497 let sql = format!(
2498 "SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" ORDER BY \"{col}\" ASC LIMIT {lim}",
2499 col = display_col,
2500 table = resolved.target_table,
2501 lim = cap + 1,
2502 );
2503 let rows = match sqlx::query(&sql).fetch_all(db.pool()).await {
2504 Ok(r) => r,
2505 Err(_) => continue,
2506 };
2507 if rows.len() > cap {
2508 continue;
2511 }
2512 let options: Vec<(i64, String)> = rows
2513 .into_iter()
2514 .map(|row| {
2515 let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
2516 let label: String = row
2517 .try_get::<String, _>("rio_label")
2518 .or_else(|_| row.try_get::<i64, _>("rio_label").map(|n| n.to_string()))
2519 .or_else(|_| row.try_get::<i32, _>("rio_label").map(|n| n.to_string()))
2520 .unwrap_or_default();
2521 (id, label)
2522 })
2523 .collect();
2524 out.insert(resolved.source_field.clone(), options);
2525 }
2526 out
2527}
2528
2529async fn fetch_fk_labels<T: AdminModel>(
2530 db: &Db,
2531 items: &[&T],
2532 registry: &relations::RelationRegistry,
2533) -> FkLabels {
2534 use sqlx::Row;
2535 let mut out: FkLabels = std::collections::HashMap::new();
2536 let source_model = T::singular_name();
2537 for f in T::FIELDS {
2538 let Some(resolved) = registry.belongs_to(source_model, f.name) else {
2539 continue;
2540 };
2541 let Some(display_col) = &resolved.target_display_field else {
2542 continue;
2547 };
2548 let mut ids: Vec<i64> = items
2550 .iter()
2551 .filter_map(|it| it.field_display(f.name))
2552 .filter_map(|s| s.parse::<i64>().ok())
2553 .collect();
2554 ids.sort_unstable();
2555 ids.dedup();
2556 if ids.is_empty() {
2557 continue;
2558 }
2559 let placeholders: Vec<&'static str> = ids.iter().map(|_| "?").collect();
2562 let sql = format!(
2563 "SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" WHERE id IN ({ph})",
2564 col = display_col,
2565 table = resolved.target_table,
2566 ph = placeholders.join(","),
2567 );
2568 let mut q = sqlx::query(&sql);
2569 for id in &ids {
2570 q = q.bind(id);
2571 }
2572 let rows = match q.fetch_all(db.pool()).await {
2573 Ok(r) => r,
2574 Err(_) => continue,
2578 };
2579 let mut map: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
2580 for row in rows {
2581 let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
2582 let label: String = row
2583 .try_get::<String, _>("rio_label")
2584 .or_else(|_| row.try_get::<i64, _>("rio_label").map(|n| n.to_string()))
2585 .or_else(|_| row.try_get::<i32, _>("rio_label").map(|n| n.to_string()))
2586 .or_else(|_| row.try_get::<bool, _>("rio_label").map(|b| b.to_string()))
2587 .unwrap_or_default();
2588 map.insert(id, label);
2589 }
2590 out.insert(f.name.to_string(), map);
2591 }
2592 out
2593}
2594
2595fn list_response<T: AdminModel>(
2596 shell: Shell<'_>,
2597 items: &[&T],
2598 total: usize,
2599 filters: ListFilters<'_>,
2600 cell_ctx: &CellCtx<'_>,
2601) -> Response {
2602 let count = items.len();
2603 let singular = T::singular_name();
2604 let plural = T::DISPLAY_NAME;
2605 let admin_name = T::ADMIN_NAME;
2606
2607 let page_actions = format!(
2608 r#"<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{icon}<span>Add {singular}</span></a>"#,
2609 name = escape_html(admin_name),
2610 singular = escape_html(singular),
2611 icon = icon_plus(),
2612 );
2613
2614 let body = if total == 0 {
2619 let hint_html = match empty_state_hint::<T>(intelligence::context_global()) {
2620 Some(h) => format!(r#"<p class="rio-empty-hint">{}</p>"#, escape_html(&h)),
2621 None => String::new(),
2622 };
2623 format!(
2624 r#"<div class="rio-card">
2625<div class="rio-empty">
2626<div class="rio-empty-icon">{icon}</div>
2627<h3>Start by adding your first {singular_lower}</h3>
2628<p>This table is empty. Create the first record to get started.</p>
2629{hint}
2630<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{plus}<span>Add {singular_lower}</span></a>
2631</div>
2632</div>"#,
2633 icon = icon_inbox(),
2634 name = escape_html(admin_name),
2635 plus = icon_plus(),
2636 singular_lower = escape_html(&singular.to_lowercase()),
2637 hint = hint_html,
2638 )
2639 } else {
2640 let toolbar = render_list_toolbar::<T>(&filters, count, total);
2641 let chips = render_active_filter_chips(&filters, admin_name);
2646
2647 if items.is_empty() {
2648 format!(
2649 r#"<div class="rio-table-wrap">
2650{toolbar}
2651{chips}
2652<div class="rio-empty">
2653<div class="rio-empty-icon">{icon}</div>
2654<h3>No records match these filters</h3>
2655<p>Try a different search term, clear the filters, or add a new {singular_lower}.</p>
2656<div class="rio-empty-actions">
2657<a class="rio-btn" href="/admin/{name}">{reset}<span>Clear filters</span></a>
2658<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{plus}<span>Add {singular_lower}</span></a>
2659</div>
2660</div>
2661</div>"#,
2662 icon = icon_search(),
2663 singular_lower = escape_html(&singular.to_lowercase()),
2664 name = escape_html(admin_name),
2665 reset = icon_arrow_left(),
2666 plus = icon_plus(),
2667 )
2668 } else {
2669 let visible_fields: Vec<&AdminField> = T::FIELDS
2675 .iter()
2676 .filter(|f| filters.visible_columns.contains(&f.name))
2677 .collect();
2678 let has_hidden_fields = T::FIELDS.len() > visible_fields.len();
2683 let hidden_fields: Vec<&AdminField> = T::FIELDS
2684 .iter()
2685 .filter(|f| !filters.visible_columns.contains(&f.name))
2686 .collect();
2687 let colspan_total = visible_fields.len() + 3;
2693 let headers: String = visible_fields
2699 .iter()
2700 .map(|f| {
2701 format!(
2702 r#"<th data-col="{name}">{label}</th>"#,
2703 name = escape_html(f.name),
2704 label = escape_html(&humanise(f.name)),
2705 )
2706 })
2707 .collect();
2708 let expand_header = if has_hidden_fields {
2709 r#"<th class="rio-cell-expand" aria-label="Expand"></th>"#.to_string()
2710 } else {
2711 String::new()
2712 };
2713 let rows: String = items
2714 .iter()
2715 .map(|item| {
2716 let cells: String = visible_fields
2717 .iter()
2718 .map(|f| {
2719 let cell = render_cell::<T>(f, *item, cell_ctx);
2720 inject_data_col(&cell, f.name)
2721 })
2722 .collect();
2723 let id = item.id();
2724 let row_actions = format!(
2728 r#"<td class="rio-cell-actions">
2729<div class="rio-row-actions">
2730<a class="rio-btn rio-btn-sm" href="/admin/{name}/{id}/edit">{pencil}<span>Edit</span></a>
2731<a class="rio-btn rio-btn-sm rio-btn-danger-ghost" href="/admin/{name}/{id}/delete" rel="nofollow">{trash}<span>Delete</span></a>
2732</div>
2733</td>"#,
2734 name = escape_html(admin_name),
2735 id = id,
2736 pencil = icon_pencil(),
2737 trash = icon_trash(),
2738 );
2739 let checkbox = format!(
2740 r#"<td class="rio-cell-check"><input type="checkbox" class="rio-bulk-row" value="{id}" aria-label="Select row {id}"></td>"#,
2741 );
2742 if !has_hidden_fields {
2743 return format!("<tr>{checkbox}{cells}{row_actions}</tr>");
2744 }
2745 let expand_cell = format!(
2750 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>"#,
2751 );
2752 let detail_fields: String = hidden_fields
2753 .iter()
2754 .map(|f| {
2755 format!(
2756 r#"<div class="rio-expand-field"><dt>{label}</dt><dd>{value}</dd></div>"#,
2757 label = escape_html(&humanise(f.name)),
2758 value = render_cell_inner::<T>(f, *item, cell_ctx),
2759 )
2760 })
2761 .collect();
2762 let expand_row = format!(
2763 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>"#,
2764 id = id,
2765 colspan = colspan_total,
2766 fields = detail_fields,
2767 );
2768 format!(
2769 r#"<tr class="rio-row-main" data-row-id="{id}">{expand_cell}{checkbox}{cells}{row_actions}</tr>{expand_row}"#,
2770 )
2771 })
2772 .collect();
2773
2774 let csrf = csrf_input(shell.csrf);
2775 let bulk_bar = format!(
2776 r#"<div class="rio-bulk-bar">
2777<label class="rio-bulk-label" for="rio-bulk-action">Action</label>
2778<select class="rio-select" id="rio-bulk-action" name="action">
2779<option value="">-- Select an action --</option>
2780<option value="delete">Delete selected {plural_lower}</option>
2781</select>
2782<button type="submit" class="rio-btn">Go</button>
2783<span class="rio-bulk-count" data-rio-bulk-count>0 selected</span>
2784</div>"#,
2785 plural_lower = escape_html(&plural.to_lowercase()),
2786 );
2787
2788 format!(
2789 r#"<div class="rio-table-wrap">
2790{toolbar}
2791{chips}
2792<form method="post" action="/admin/{name}/bulk_action" class="rio-bulk-form">
2793{csrf}
2794<input type="hidden" name="_selected" value="">
2795{bulk_bar}
2796<table class="rio-table">
2797<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>
2798<tbody>{rows}</tbody>
2799</table>
2800</form>
2801<script>
2802(function(){{
2803var form=document.querySelector('.rio-bulk-form');
2804if(form){{
2805 var all=form.querySelector('.rio-bulk-all');
2806 var rows=form.querySelectorAll('.rio-bulk-row');
2807 var count=form.querySelector('[data-rio-bulk-count]');
2808 var hidden=form.querySelector('input[name="_selected"]');
2809 function collect(){{var ids=[];rows.forEach(function(cb){{if(cb.checked)ids.push(cb.value);}});return ids;}}
2810 function update(){{var ids=collect();if(hidden)hidden.value=ids.join(',');if(count)count.textContent=ids.length+' selected';}}
2811 if(all)all.addEventListener('change',function(){{rows.forEach(function(cb){{cb.checked=all.checked;}});update();}});
2812 rows.forEach(function(cb){{cb.addEventListener('change',update);}});
2813 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.');}}}});
2814 update();
2815}}
2816// Columns toggle (Change 2) — outside-click closes the <details>,
2817// and checkbox changes flip `display: none` on the matching <th>
2818// and every matching <td> via `data-col` attribute. Checkbox and
2819// actions columns carry no `data-col`, so they're never touched.
2820document.addEventListener('click',function(e){{
2821 var d=document.querySelector('details.rio-cols-ctl[open]');
2822 if(!d)return;
2823 if(d.contains(e.target))return;
2824 d.open=false;
2825}});
2826document.addEventListener('change',function(e){{
2827 var cb=e.target&&e.target.closest?e.target.closest('.rio-cols-check'):null;
2828 if(!cb)return;
2829 var col=cb.getAttribute('data-col');
2830 if(!col)return;
2831 var esc=(window.CSS&&CSS.escape)?CSS.escape(col):col;
2832 document.querySelectorAll('[data-col="'+esc+'"]').forEach(function(cell){{
2833 cell.style.display=cb.checked?'':'none';
2834 }});
2835}});
2836// More filters panel toggle (Change 3) — plain hidden-attribute
2837// flip. Button carries `data-more-filters-toggle` and an
2838// `aria-controls` pointing at the panel id. No outside-click
2839// handler, no animation.
2840document.addEventListener('click',function(e){{
2841 var btn=e.target&&e.target.closest?e.target.closest('[data-more-filters-toggle]'):null;
2842 if(!btn)return;
2843 var id=btn.getAttribute('aria-controls');
2844 var panel=id?document.getElementById(id):null;
2845 if(!panel)return;
2846 var open=!panel.hasAttribute('hidden');
2847 if(open){{
2848 panel.setAttribute('hidden','');
2849 btn.setAttribute('aria-expanded','false');
2850 }}else{{
2851 panel.removeAttribute('hidden');
2852 btn.setAttribute('aria-expanded','true');
2853 }}
2854}});
2855// Row expansion toggle (Change 5) — the button lives in the first
2856// column of each `.rio-row-main`, the paired `.rio-row-expand` is
2857// its `nextElementSibling`. Flip the `hidden` attribute + chevron
2858// glyph + aria-expanded; nothing else.
2859document.addEventListener('click',function(e){{
2860 var btn=e.target&&e.target.closest?e.target.closest('[data-expand-toggle]'):null;
2861 if(!btn)return;
2862 var main=btn.closest('tr');
2863 if(!main)return;
2864 var panel=main.nextElementSibling;
2865 if(!panel||!panel.classList.contains('rio-row-expand'))return;
2866 var open=!panel.hasAttribute('hidden');
2867 if(open){{
2868 panel.setAttribute('hidden','');
2869 btn.setAttribute('aria-expanded','false');
2870 btn.textContent='\u25B8';
2871 }}else{{
2872 panel.removeAttribute('hidden');
2873 btn.setAttribute('aria-expanded','true');
2874 btn.textContent='\u25BE';
2875 }}
2876}});
2877}})();
2878</script>
2879</div>"#,
2880 name = escape_html(admin_name),
2881 expand_header = expand_header,
2882 )
2883 }
2884 };
2885
2886 let crumbs: &[Crumb<'_>] = &[("Admin", Some("/admin")), (plural, None)];
2887
2888 render_shell_page(
2889 &shell,
2890 200,
2891 plural,
2892 plural,
2893 Some(&format!(
2894 "Browse, search, and manage {}.",
2895 plural.to_lowercase()
2896 )),
2897 crumbs,
2898 &page_actions,
2899 &body,
2900 )
2901}
2902
2903fn render_relation_filter_control(state: &RelationFilterState) -> String {
2910 let field = escape_html(&state.field_name);
2911 let label = escape_html(&state.label);
2912 match &state.mode {
2913 RelationFilterMode::Dropdown { options } => {
2914 let placeholder = format!("All {}", pluralise_label(&state.label));
2917 let options_html: String = std::iter::once(format!(
2918 r#"<option value="">{}</option>"#,
2919 escape_html(&placeholder),
2920 ))
2921 .chain(options.iter().map(|(id, display)| {
2922 let selected = state.current_value == Some(*id);
2923 let mark = if selected { " selected" } else { "" };
2924 format!(
2925 r#"<option value="{id}"{mark}>{display}</option>"#,
2926 id = id,
2927 mark = mark,
2928 display = escape_html(display),
2929 )
2930 }))
2931 .collect();
2932 format!(
2933 r#"<select class="rio-select" name="{field}" aria-label="Filter by {label}">{options_html}</select>"#,
2934 )
2935 }
2936 RelationFilterMode::Numeric { too_many } => {
2937 let current = state
2938 .current_value
2939 .map(|v| v.to_string())
2940 .unwrap_or_default();
2941 let hint = if *too_many {
2945 format!(
2946 r#"<span class="rio-field-hint">Too many options for a dropdown — enter the {label} ID directly.</span>"#,
2947 label = label,
2948 )
2949 } else {
2950 format!(
2951 r#"<span class="rio-field-hint">No display field declared for {label} — enter the ID directly.</span>"#,
2952 label = label,
2953 )
2954 };
2955 format!(
2956 r#"<label class="rio-field" style="display:inline-flex; gap:var(--rio-s-1); align-items:center; margin:0">\
2957<span class="rio-field-label">{label} ID</span>\
2958<input class="rio-input" type="number" name="{field}" value="{current}" style="width:140px" aria-label="Filter by {label} id">\
2959{hint}\
2960</label>"#,
2961 label = label,
2962 field = field,
2963 current = escape_html(¤t),
2964 hint = hint,
2965 )
2966 }
2967 }
2968}
2969
2970fn render_columns_control<T: AdminModel>(filters: &ListFilters<'_>) -> String {
2982 let rows: String = T::FIELDS
2983 .iter()
2984 .map(|f| {
2985 let is_visible = filters.visible_columns.contains(&f.name);
2986 let is_id = f.name == "id";
2987 let checked = if is_visible { " checked" } else { "" };
2988 let disabled = if is_id { " disabled" } else { "" };
2989 let mut tags: Vec<&'static str> = Vec::new();
2990 if is_visible {
2991 tags.push("primary");
2992 }
2993 if f.relation.is_some() {
2994 tags.push("relation");
2995 }
2996 let tag_html = if tags.is_empty() {
2997 String::new()
2998 } else {
2999 format!(" <small>{}</small>", tags.join(" · "))
3000 };
3001 format!(
3002 r#"<label class="rio-cols-panel-row"><input type="checkbox" class="rio-cols-check" data-col="{name}"{checked}{disabled}><span>{label}{tags}</span></label>"#,
3003 name = escape_html(f.name),
3004 label = escape_html(&humanise(f.name)),
3005 tags = tag_html,
3006 )
3007 })
3008 .collect();
3009
3010 format!(
3011 r#"<details class="rio-cols-ctl"><summary class="rio-btn">Columns</summary><div class="rio-cols-panel">{rows}</div></details>"#,
3012 )
3013}
3014
3015fn build_list_url(admin_name: &str, params: &[(String, String)]) -> String {
3021 if params.is_empty() {
3022 return format!("/admin/{}", admin_name);
3023 }
3024 let query: String = params
3025 .iter()
3026 .map(|(k, v)| format!("{}={}", k, v))
3027 .collect::<Vec<_>>()
3028 .join("&");
3029 format!("/admin/{}?{}", admin_name, query)
3030}
3031
3032fn current_filter_params(filters: &ListFilters<'_>) -> Vec<(String, String)> {
3038 let mut params: Vec<(String, String)> = Vec::new();
3039 if let Some(q) = filters.q.filter(|s| !s.is_empty()) {
3040 params.push(("q".to_string(), q.to_string()));
3041 }
3042 if let Some(s) = filters.status {
3043 params.push(("status".to_string(), s.to_string()));
3044 }
3045 if let Some(p) = filters.priority {
3046 params.push(("priority".to_string(), p.to_string()));
3047 }
3048 for r in filters.relation_filters {
3049 if let Some(id) = r.current_value {
3050 params.push((r.field_name.clone(), id.to_string()));
3051 }
3052 }
3053 if let Some(sort) = filters.sort {
3054 params.push(("sort".to_string(), sort.to_string()));
3055 }
3056 params
3057}
3058
3059fn render_active_filter_chips(filters: &ListFilters<'_>, admin_name: &str) -> String {
3067 let all_params = current_filter_params(filters);
3068 let remove_url = |exclude_key: &str| -> String {
3069 let kept: Vec<(String, String)> = all_params
3070 .iter()
3071 .filter(|(k, _)| k != exclude_key)
3072 .cloned()
3073 .collect();
3074 build_list_url(admin_name, &kept)
3075 };
3076
3077 let mut chips: Vec<String> = Vec::new();
3078
3079 if let Some(q) = filters.q.filter(|s| !s.is_empty()) {
3080 chips.push(format!(
3081 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>"#,
3082 value = escape_html(q),
3083 href = escape_html(&remove_url("q")),
3084 ));
3085 }
3086 if let Some(s) = filters.status {
3087 chips.push(format!(
3088 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>"#,
3089 value = escape_html(&humanise_enum_value(s)),
3090 href = escape_html(&remove_url("status")),
3091 ));
3092 }
3093 if let Some(p) = filters.priority {
3094 chips.push(format!(
3095 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>"#,
3096 value = escape_html(p),
3097 href = escape_html(&remove_url("priority")),
3098 ));
3099 }
3100 for r in filters.relation_filters {
3101 if let Some(id) = r.current_value {
3102 let display = match &r.mode {
3103 RelationFilterMode::Dropdown { options } => options
3104 .iter()
3105 .find(|(opt_id, _)| *opt_id == id)
3106 .map(|(_, name)| name.clone())
3107 .unwrap_or_else(|| format!("#{}", id)),
3108 RelationFilterMode::Numeric { .. } => format!("#{}", id),
3109 };
3110 chips.push(format!(
3111 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>"#,
3112 label = escape_html(&r.label),
3113 value = escape_html(&display),
3114 href = escape_html(&remove_url(&r.field_name)),
3115 ));
3116 }
3117 }
3118
3119 if chips.is_empty() {
3120 return String::new();
3121 }
3122
3123 format!(
3124 r#"<div class="admin-filter-chips">{chips}<a class="admin-filter-clear-all" href="/admin/{name}">Clear all</a></div>"#,
3125 chips = chips.join(""),
3126 name = escape_html(admin_name),
3127 )
3128}
3129
3130fn render_more_filters_panel(
3137 status_select: &str,
3138 priority_select: &str,
3139 secondary_relations_html: &str,
3140) -> String {
3141 if status_select.is_empty() && priority_select.is_empty() && secondary_relations_html.is_empty()
3142 {
3143 return String::new();
3144 }
3145 format!(
3146 r#"<div class="admin-list-more-filters" id="more-filters-panel" hidden><div class="admin-filter-grid">{status}{priority}{relations}</div></div>"#,
3147 status = status_select,
3148 priority = priority_select,
3149 relations = secondary_relations_html,
3150 )
3151}
3152
3153fn render_list_toolbar<T: AdminModel>(
3154 filters: &ListFilters<'_>,
3155 shown: usize,
3156 total: usize,
3157) -> String {
3158 let admin_name = T::ADMIN_NAME;
3159 let plural = T::DISPLAY_NAME;
3160
3161 let q_value = filters.q.map(escape_html).unwrap_or_default();
3162
3163 let status_select = if !filters.status_options.is_empty() {
3169 let options: String =
3170 std::iter::once(r#"<option value="">All statuses</option>"#.to_string())
3171 .chain(filters.status_options.iter().map(|v| {
3172 let selected = if filters.status.map(|s| s == v).unwrap_or(false) {
3173 " selected"
3174 } else {
3175 ""
3176 };
3177 format!(
3178 r#"<option value="{v}"{selected}>{label}</option>"#,
3179 v = escape_html(v),
3180 label = escape_html(&humanise_enum_value(v)),
3181 )
3182 }))
3183 .collect();
3184 format!(
3185 r#"<select class="rio-select" name="status" aria-label="Filter by status">{options}</select>"#,
3186 )
3187 } else {
3188 String::new()
3189 };
3190
3191 let priority_select = if !filters.priority_options.is_empty() {
3195 let mut sorted_priorities: Vec<&String> = filters.priority_options.iter().collect();
3196 sorted_priorities.sort_by(|a, b| {
3197 let na: Option<i64> = a.parse().ok();
3198 let nb: Option<i64> = b.parse().ok();
3199 match (na, nb) {
3200 (Some(x), Some(y)) => x.cmp(&y),
3201 _ => a.cmp(b),
3202 }
3203 });
3204 let options: String =
3205 std::iter::once(r#"<option value="">All priorities</option>"#.to_string())
3206 .chain(sorted_priorities.iter().map(|v| {
3207 let selected = if filters.priority.map(|p| p == v.as_str()).unwrap_or(false) {
3208 " selected"
3209 } else {
3210 ""
3211 };
3212 format!(
3213 r#"<option value="{v}"{selected}>Priority {v}</option>"#,
3214 v = escape_html(v),
3215 )
3216 }))
3217 .collect();
3218 format!(
3219 r#"<select class="rio-select" name="priority" aria-label="Filter by priority">{options}</select>"#,
3220 )
3221 } else {
3222 String::new()
3223 };
3224
3225 let (primary_relation_html, secondary_relations_html): (String, String) = {
3229 let mut iter = filters.relation_filters.iter();
3230 let first = iter
3231 .next()
3232 .map(render_relation_filter_control)
3233 .unwrap_or_default();
3234 let rest: String = iter.map(render_relation_filter_control).collect();
3235 (first, rest)
3236 };
3237
3238 let secondary_active_count: usize = filters.status.is_some() as usize
3242 + filters.priority.is_some() as usize
3243 + filters
3244 .relation_filters
3245 .iter()
3246 .skip(1)
3247 .filter(|r| r.current_value.is_some())
3248 .count();
3249
3250 let more_filters_panel_html =
3255 render_more_filters_panel(&status_select, &priority_select, &secondary_relations_html);
3256 let more_filters_btn = if more_filters_panel_html.is_empty() {
3257 String::new()
3258 } else {
3259 let label = if secondary_active_count == 0 {
3260 "More filters".to_string()
3261 } else {
3262 format!("More filters ({secondary_active_count})")
3263 };
3264 format!(
3265 r#"<button type="button" class="rio-btn" data-more-filters-toggle aria-controls="more-filters-panel" aria-expanded="false">{label}</button>"#,
3266 )
3267 };
3268
3269 let reset_btn = if filters.is_active() {
3270 format!(
3271 r#"<a class="rio-btn rio-btn-ghost" href="/admin/{name}">Reset</a>"#,
3272 name = escape_html(admin_name),
3273 )
3274 } else {
3275 String::new()
3276 };
3277
3278 let sort_select = {
3283 let current = filters.sort.unwrap_or("newest");
3284 let options: String = SORT_OPTIONS
3285 .iter()
3286 .map(|(value, label)| {
3287 let sel = if *value == current { " selected" } else { "" };
3288 format!(
3289 r#"<option value="{v}"{sel}>{l}</option>"#,
3290 v = escape_html(value),
3291 l = escape_html(label),
3292 )
3293 })
3294 .collect();
3295 format!(
3296 r#"<select class="rio-select rio-select-sort" name="sort" aria-label="Sort records">{options}</select>"#,
3297 )
3298 };
3299
3300 let count_label = if filters.is_active() {
3301 format!("Showing {shown} of {total}")
3302 } else if total == 1 {
3303 "1 record".to_string()
3304 } else {
3305 format!("{total} records")
3306 };
3307
3308 let intent_badge = filters
3313 .q
3314 .filter(|q| !q.is_empty())
3315 .map(|q| match intelligence::classify_search(q) {
3316 intelligence::SearchIntent::Text(_) => String::new(),
3317 other => format!(
3318 r#"<span class="rio-search-intent">Interpreted as: {}</span>"#,
3319 escape_html(other.label()),
3320 ),
3321 })
3322 .unwrap_or_default();
3323
3324 let columns_control = render_columns_control::<T>(filters);
3330
3331 format!(
3332 r#"<form class="rio-table-toolbar" method="get" action="/admin/{name}" role="search" aria-label="Search {plural}">
3333<div class="rio-search">
3334{search_icon}
3335<input type="search" name="q" value="{q}" placeholder="Search {plural_lower}…" aria-label="Search text">
3336{intent}
3337</div>
3338{primary_relation}
3339{sort}
3340<div class="rio-toolbar-actions">
3341<button type="submit" class="rio-btn rio-btn-primary">{submit_icon}<span>Search</span></button>
3342{more_filters_btn}
3343{reset}
3344{columns}
3345</div>
3346<div class="rio-count">{count}</div>
3347{more_filters_panel}
3348</form>"#,
3349 name = escape_html(admin_name),
3350 plural = escape_html(plural),
3351 plural_lower = escape_html(&plural.to_lowercase()),
3352 search_icon = icon_search(),
3353 q = q_value,
3354 intent = intent_badge,
3355 primary_relation = primary_relation_html,
3356 sort = sort_select,
3357 submit_icon = icon_search(),
3358 more_filters_btn = more_filters_btn,
3359 reset = reset_btn,
3360 columns = columns_control,
3361 count = escape_html(&count_label),
3362 more_filters_panel = more_filters_panel_html,
3363 )
3364}
3365
3366fn matches_query<T: AdminModel>(item: &T, needle: &str) -> bool {
3371 let needle = needle.to_lowercase();
3372 if item.id().to_string().contains(&needle) {
3373 return true;
3374 }
3375 for f in T::FIELDS.iter() {
3376 if matches!(f.ty, FieldType::String) {
3377 if let Some(v) = item.field_display(f.name) {
3378 if v.to_lowercase().contains(&needle) {
3379 return true;
3380 }
3381 }
3382 }
3383 }
3384 false
3385}
3386
3387fn distinct_values<T: AdminModel>(items: &[T], field_name: &str) -> Vec<String> {
3391 if !T::FIELDS.iter().any(|f| f.name == field_name) {
3392 return Vec::new();
3393 }
3394 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3395 for item in items {
3396 if let Some(v) = item.field_display(field_name) {
3397 if !v.is_empty() {
3398 set.insert(v);
3399 }
3400 }
3401 }
3402 set.into_iter().collect()
3403}
3404
3405fn status_pill_class(value: &str) -> &'static str {
3409 match value {
3410 "done" | "complete" | "completed" | "finished" | "resolved" => "rio-pill rio-pill-emerald",
3411 "active" | "approved" | "published" | "live" => "rio-pill rio-pill-emerald",
3412 "pending" | "todo" | "queued" | "open" | "new" => "rio-pill rio-pill-amber",
3413 "in_progress" | "doing" | "working" | "review" | "in_review" => "rio-pill rio-pill-indigo",
3414 "archived" | "inactive" | "closed" | "cancelled" | "canceled" => "rio-pill rio-pill-slate",
3415 "blocked" | "failed" | "rejected" | "error" => "rio-pill rio-pill-rose",
3416 _ => "rio-pill rio-pill-slate",
3417 }
3418}
3419
3420fn inject_data_col(cell: &str, col: &str) -> String {
3438 let trimmed_offset = cell.len() - cell.trim_start().len();
3439 let rest = &cell[trimmed_offset..];
3440 if !rest.starts_with("<td") {
3441 return cell.to_string();
3442 }
3443 let (leading_ws, after_ws) = cell.split_at(trimmed_offset);
3444 let after_td = &after_ws[3..];
3446 format!(
3447 r#"{leading_ws}<td data-col="{col}"{after_td}"#,
3448 col = escape_html(col),
3449 )
3450}
3451
3452fn render_cell<T: AdminModel>(f: &AdminField, item: &T, ctx: &CellCtx<'_>) -> String {
3453 let value = item.field_display(f.name).unwrap_or_default();
3454 if f.name == "id" {
3455 return format!(r#"<td class="rio-cell-id">#{}</td>"#, escape_html(&value));
3456 }
3457 if value.is_empty() && f.nullable {
3458 return r#"<td class="rio-cell-muted">—</td>"#.to_string();
3459 }
3460 if let Some(resolved) = ctx.registry.belongs_to(T::singular_name(), f.name) {
3464 if let Ok(id) = value.parse::<i64>() {
3465 let label = ctx.fk_labels.get(f.name).and_then(|m| m.get(&id));
3466 let admin = escape_html(&resolved.target_admin_name);
3467 return match (label, &resolved.target_display_field) {
3468 (Some(name), _) => format!(
3470 r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">{name}</a> <span class="rio-cell-id">#{id}</span></td>"#,
3471 admin = admin,
3472 id = id,
3473 name = escape_html(name),
3474 ),
3475 (None, Some(_)) => format!(
3479 r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">#{id}</a></td>"#,
3480 admin = admin,
3481 id = id,
3482 ),
3483 (None, None) => format!(
3486 r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">#{id}</a></td>"#,
3487 admin = admin,
3488 id = id,
3489 ),
3490 };
3491 }
3492 }
3495 let ctx = intelligence::context_global();
3499 let ui = intelligence::field_ui_metadata(f, ctx);
3500 if ui.sensitive && !value.is_empty() {
3501 let masked = intelligence::mask_pii(&value);
3502 return format!(
3503 r#"<td class="rio-cell-muted">\
3504<span class="rio-pii" data-value="{real}" data-mask="{mask}" data-hidden="1">{mask}</span>\
3505<button class="rio-pii-toggle" type="button" aria-label="Reveal value">show</button>\
3506</td>"#,
3507 real = escape_html(&value),
3508 mask = escape_html(&masked),
3509 );
3510 }
3511 if matches!(f.ty, FieldType::Bool) {
3512 let (cls, label) = match value.as_str() {
3513 "true" => ("rio-pill rio-pill-emerald", "active"),
3514 "false" => ("rio-pill rio-pill-slate", "inactive"),
3515 other => ("rio-pill rio-pill-slate", other),
3516 };
3517 return format!(
3518 r#"<td><span class="{cls}">{}</span></td>"#,
3519 escape_html(label)
3520 );
3521 }
3522 if f.name == "status" && matches!(f.ty, FieldType::String) {
3525 let cls = status_pill_class(&value);
3526 let label = value.replace('_', " ");
3527 return format!(
3528 r#"<td><span class="{cls}">{}</span></td>"#,
3529 escape_html(&label)
3530 );
3531 }
3532 if matches!(f.ty, FieldType::I32 | FieldType::I64) {
3534 return format!(r#"<td class="rio-cell-num">{}</td>"#, escape_html(&value));
3535 }
3536 let is_primary = f.name != "id"
3538 && T::FIELDS
3539 .iter()
3540 .find(|x| x.name != "id")
3541 .map(|first| first.name == f.name)
3542 .unwrap_or(false);
3543 let cls = if is_primary {
3544 "rio-cell-primary"
3545 } else {
3546 "rio-cell-muted"
3547 };
3548 format!(r#"<td class="{cls}">{}</td>"#, escape_html(&value))
3549}
3550
3551fn render_cell_inner<T: AdminModel>(f: &AdminField, item: &T, ctx: &CellCtx<'_>) -> String {
3557 let cell = render_cell::<T>(f, item, ctx);
3558 let start = cell.find('>').map(|i| i + 1).unwrap_or(0);
3559 let end = cell.rfind("</td>").unwrap_or(cell.len());
3560 cell[start..end].to_string()
3561}
3562
3563enum FormMode<'a, T: AdminModel> {
3568 Create,
3569 Edit { id: i64, item: &'a T },
3570}
3571
3572fn form_response<T: AdminModel>(
3573 shell: Shell<'_>,
3574 mode: FormMode<'_, T>,
3575 cell_ctx: &CellCtx<'_>,
3576 inverse_counts: &InverseCounts,
3577 form_options: &FormRelationOptions,
3578) -> Response {
3579 let plural = T::DISPLAY_NAME;
3580 let singular = T::singular_name();
3581 let admin_name = T::ADMIN_NAME;
3582
3583 let (heading, doc_title, subtitle, action, back_label) = match &mode {
3584 FormMode::Create => (
3585 format!("New {singular}"),
3586 format!("New {singular}"),
3587 format!("Create a new {} record.", singular.to_lowercase()),
3588 format!("/admin/{admin_name}/create"),
3589 format!("Back to {}", plural.to_lowercase()),
3590 ),
3591 FormMode::Edit { id, .. } => (
3592 format!("Edit {singular}"),
3593 format!("Edit {singular} #{id}"),
3594 format!("Update this {} record.", singular.to_lowercase()),
3595 format!("/admin/{admin_name}/{id}/edit"),
3596 format!("Back to {}", plural.to_lowercase()),
3597 ),
3598 };
3599
3600 let fields: String = T::FIELDS
3601 .iter()
3602 .filter(|f| f.editable)
3603 .map(|f| {
3604 render_field_block::<T>(
3605 f,
3606 match &mode {
3607 FormMode::Create => None,
3608 FormMode::Edit { item, .. } => Some(*item),
3609 },
3610 cell_ctx,
3611 form_options,
3612 )
3613 })
3614 .collect();
3615
3616 let meta_block = match &mode {
3617 FormMode::Create => String::new(),
3618 FormMode::Edit { id, item } => render_meta::<T>(*id, item),
3619 };
3620
3621 let inverse_panel = match &mode {
3627 FormMode::Create => String::new(),
3628 FormMode::Edit { id, .. } => {
3629 render_inverse_panel::<T>(cell_ctx.registry, inverse_counts, *id)
3630 }
3631 };
3632
3633 let danger_zone = match &mode {
3634 FormMode::Create => String::new(),
3635 FormMode::Edit { id, .. } => format!(
3636 r#"<section class="rio-danger-zone">
3637<div class="rio-danger-copy">
3638<h3 class="rio-danger-title">{warn}<span>Delete this {singular}</span></h3>
3639<p class="rio-danger-hint">Permanently removes this record. Rows that reference it with <code>ON DELETE CASCADE</code> will also be deleted.</p>
3640</div>
3641<a class="rio-btn rio-btn-danger" href="/admin/{name}/{id}/delete" rel="nofollow">{trash}<span>Delete record</span></a>
3642</section>"#,
3643 warn = icon_triangle_alert(),
3644 singular = escape_html(&singular.to_lowercase()),
3645 name = escape_html(admin_name),
3646 id = id,
3647 trash = icon_trash(),
3648 ),
3649 };
3650
3651 let csrf_hidden = csrf_input(shell.csrf);
3652
3653 let body = format!(
3654 r#"{meta}
3655<form class="rio-card rio-form" method="post" action="{action}" autocomplete="off">
3656{csrf}
3657<div class="rio-form-section">
3658<h2 class="rio-form-section-title">Details</h2>
3659<p class="rio-form-section-hint">Fields marked optional accept an empty value.</p>
3660{fields}
3661</div>
3662<div class="rio-form-footer">
3663<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back_icon}<span>{back_label}</span></a>
3664<div class="rio-footer-actions">
3665<a class="rio-btn" href="/admin/{name}">Cancel</a>
3666<button class="rio-btn rio-btn-primary" type="submit">Save</button>
3667</div>
3668</div>
3669</form>
3670{inverse}
3671{danger}"#,
3672 meta = meta_block,
3673 action = escape_html(&action),
3674 csrf = csrf_hidden,
3675 fields = fields,
3676 name = escape_html(admin_name),
3677 back_icon = icon_arrow_left(),
3678 back_label = escape_html(&back_label),
3679 inverse = inverse_panel,
3680 danger = danger_zone,
3681 );
3682
3683 let plural_href = format!("/admin/{admin_name}");
3687 let crumbs: Vec<Crumb<'_>> = match &mode {
3688 FormMode::Create => vec![
3689 ("Admin", Some("/admin")),
3690 (plural, Some(plural_href.as_str())),
3691 ("New", None),
3692 ],
3693 FormMode::Edit { .. } => vec![
3694 ("Admin", Some("/admin")),
3695 (plural, Some(plural_href.as_str())),
3696 ("Edit", None),
3697 ],
3698 };
3699
3700 let page_actions = match &mode {
3704 FormMode::Create => String::new(),
3705 FormMode::Edit { id, .. } => format!(
3706 r#"<a class="rio-btn" href="/admin/{name}/{id}/history">History</a>"#,
3707 name = escape_html(admin_name),
3708 id = id,
3709 ),
3710 };
3711
3712 render_shell_page(
3713 &shell,
3714 200,
3715 &doc_title,
3716 &heading,
3717 Some(&subtitle),
3718 &crumbs,
3719 &page_actions,
3720 &body,
3721 )
3722}
3723
3724fn render_field_block<T: AdminModel>(
3732 f: &AdminField,
3733 item: Option<&T>,
3734 cell_ctx: &CellCtx<'_>,
3735 form_options: &FormRelationOptions,
3736) -> String {
3737 let name = escape_html(f.name);
3738 let mut ui = intelligence::field_ui_metadata(f, intelligence::context_global());
3739 if f.relation.is_some() {
3745 ui.hint = None;
3746 ui.label = field_label(f);
3747 }
3748 let input = render_field::<T>(f, item, ui.placeholder.as_deref(), form_options);
3749
3750 if matches!(f.ty, FieldType::Bool) {
3752 return format!(
3753 r#"<div class="rio-field rio-field-row-checkbox">
3754{input}
3755<label for="_{name}">{label}</label>
3756</div>"#,
3757 input = input,
3758 name = name,
3759 label = escape_html(&ui.label),
3760 );
3761 }
3762
3763 let optional_mark = if f.nullable {
3764 r#"<span class="rio-field-optional">optional</span>"#.to_string()
3765 } else {
3766 String::new()
3767 };
3768 let sensitive_mark = if ui.sensitive {
3769 let note = ui
3770 .sensitivity_note
3771 .as_deref()
3772 .unwrap_or("Personal data — handle with care.");
3773 format!(
3774 r#"<span class="rio-field-sensitive" title="{note}">🔒 PII</span>"#,
3775 note = escape_html(note),
3776 )
3777 } else {
3778 String::new()
3779 };
3780 let hint_html = match ui.hint.as_deref() {
3781 Some(h) => format!(r#"<p class="rio-field-hint">{}</p>"#, escape_html(h),),
3782 None => String::new(),
3783 };
3784
3785 let relation_hint = render_relation_hint::<T>(f, item, cell_ctx);
3791
3792 format!(
3793 r#"<div class="rio-field">
3794<label for="_{name}">{label}{optional}{sensitive}</label>
3795{input}
3796{rel}
3797{hint}
3798</div>"#,
3799 name = name,
3800 label = escape_html(&ui.label),
3801 optional = optional_mark,
3802 sensitive = sensitive_mark,
3803 rel = relation_hint,
3804 hint = hint_html,
3805 )
3806}
3807
3808fn render_relation_hint<T: AdminModel>(
3815 f: &AdminField,
3816 item: Option<&T>,
3817 cell_ctx: &CellCtx<'_>,
3818) -> String {
3819 let Some(item) = item else {
3820 return String::new();
3821 };
3822 if f.relation.is_none() {
3823 return String::new();
3824 }
3825 let Some(resolved) = cell_ctx.registry.belongs_to(T::singular_name(), f.name) else {
3826 return String::new();
3827 };
3828 let Some(value) = item.field_display(f.name) else {
3829 return String::new();
3830 };
3831 let Ok(id) = value.parse::<i64>() else {
3832 return String::new();
3833 };
3834 let label = cell_ctx.fk_labels.get(f.name).and_then(|m| m.get(&id));
3835 let admin = escape_html(&resolved.target_admin_name);
3836 match (label, &resolved.target_display_field) {
3837 (Some(name), _) => format!(
3838 r#"<p class="rio-field-hint">Linked: <a href="/admin/{admin}/{id}">{name}</a> <span class="rio-cell-id">#{id}</span></p>"#,
3839 admin = admin,
3840 id = id,
3841 name = escape_html(name),
3842 ),
3843 (None, _) => format!(
3844 r#"<p class="rio-field-hint">Linked: <a href="/admin/{admin}/{id}">#{id}</a></p>"#,
3845 admin = admin,
3846 id = id,
3847 ),
3848 }
3849}
3850
3851fn is_foreign_key_violation(e: &Error) -> bool {
3857 matches!(e, Error::Internal(msg) if msg.contains("FOREIGN KEY constraint failed"))
3858}
3859
3860fn render_delete_blocked_page<T: AdminModel>(
3865 shell: &Shell<'_>,
3866 target_id: i64,
3867 target_primary: &str,
3868 blockers: &[(&relations::InverseRelation, i64)],
3869) -> Response {
3870 let singular = T::singular_name();
3871 let admin_name = T::ADMIN_NAME;
3872 let plural = T::DISPLAY_NAME;
3873 let subject = if target_primary.is_empty() {
3874 format!("{singular} #{target_id}")
3875 } else {
3876 format!("{target_primary} (#{target_id})")
3877 };
3878
3879 let rows: String = blockers
3880 .iter()
3881 .map(|(inv, count)| {
3882 let filter_url = format!(
3883 "/admin/{}?{}={}",
3884 inv.source_admin_name,
3885 urlencoding_light(&inv.source_field),
3886 target_id,
3887 );
3888 format!(
3889 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>"#,
3890 label = escape_html(&inv.source_display_name),
3891 label_lower = escape_html(&inv.source_display_name.to_lowercase()),
3892 field = escape_html(&inv.source_field),
3893 count = count,
3894 plural_s = if *count == 1 { "" } else { "s" },
3895 url = escape_html(&filter_url),
3896 )
3897 })
3898 .collect();
3899
3900 let back_href = format!("/admin/{}", admin_name);
3901 let body = format!(
3902 r#"<section class="rio-card">
3903<div class="rio-card-header">
3904<h2 class="rio-card-title">Cannot delete {subject}</h2>
3905<p class="rio-card-subtitle">Other records reference this one. Remove or reassign them first, then retry the delete.</p>
3906</div>
3907<ul class="rio-dashboard-alerts" style="list-style:none; margin:0; padding:var(--rio-card-pad)">
3908{rows}
3909</ul>
3910<div class="rio-form-footer">
3911<a class="rio-btn" href="{back}">Back to {plural_lower}</a>
3912</div>
3913</section>"#,
3914 subject = escape_html(&subject),
3915 rows = rows,
3916 back = escape_html(&back_href),
3917 plural_lower = escape_html(&plural.to_lowercase()),
3918 );
3919
3920 let plural_href = back_href.clone();
3921 let crumbs: Vec<Crumb<'_>> = vec![
3922 ("Admin", Some("/admin")),
3923 (plural, Some(plural_href.as_str())),
3924 ("Delete blocked", None),
3925 ];
3926 let doc_title = format!("Cannot delete {subject}");
3927 render_shell_page(
3928 shell,
3929 409,
3930 &doc_title,
3931 "Delete blocked",
3932 Some("Remove the dependent references first, then retry."),
3933 &crumbs,
3934 "",
3935 &body,
3936 )
3937}
3938
3939fn render_inverse_panel<T: AdminModel>(
3949 registry: &relations::RelationRegistry,
3950 counts: &InverseCounts,
3951 target_id: i64,
3952) -> String {
3953 let inverses = registry.has_many(T::singular_name());
3954 if inverses.is_empty() {
3955 return String::new();
3956 }
3957 let cards: String = inverses
3958 .iter()
3959 .map(|inv| {
3960 let key = format!("{}.{}", inv.source_model, inv.source_field);
3961 let count = counts.get(&key).copied().unwrap_or(0);
3962 let label = inv.source_display_name.to_string();
3963 let filter_url = format!(
3967 "/admin/{}?{}={}",
3968 inv.source_admin_name,
3969 urlencoding_light(&inv.source_field),
3970 target_id,
3971 );
3972 format!(
3973 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>"#,
3974 url = escape_html(&filter_url),
3975 label = escape_html(&label),
3976 count = count,
3977 field = escape_html(&inv.source_field),
3978 )
3979 })
3980 .collect();
3981 format!(
3982 r#"<section class="rio-card rio-related">
3983<div class="rio-card-header">
3984<h2 class="rio-card-title">Related</h2>
3985<p class="rio-card-subtitle">Incoming references to this record.</p>
3986</div>
3987<ul class="rio-related-grid">
3988{cards}
3989</ul>
3990</section>"#,
3991 cards = cards,
3992 )
3993}
3994
3995fn urlencoding_light(s: &str) -> String {
4001 let mut out = String::with_capacity(s.len());
4002 for ch in s.chars() {
4003 match ch {
4004 ' ' => out.push_str("%20"),
4005 '&' => out.push_str("%26"),
4006 '=' => out.push_str("%3D"),
4007 '#' => out.push_str("%23"),
4008 '?' => out.push_str("%3F"),
4009 _ => out.push(ch),
4010 }
4011 }
4012 out
4013}
4014
4015fn humanise(s: &str) -> String {
4017 let mut out = String::with_capacity(s.len());
4018 let mut next_upper = true;
4019 for ch in s.chars() {
4020 if ch == '_' {
4021 out.push(' ');
4022 next_upper = true;
4023 } else if next_upper {
4024 out.push(ch.to_ascii_uppercase());
4025 next_upper = false;
4026 } else {
4027 out.push(ch);
4028 }
4029 }
4030 out
4031}
4032
4033fn humanise_enum_value(s: &str) -> String {
4038 let mut out = String::with_capacity(s.len());
4039 let mut first = true;
4040 for ch in s.chars() {
4041 if ch == '_' {
4042 out.push(' ');
4043 } else if first {
4044 out.push(ch.to_ascii_uppercase());
4045 first = false;
4046 } else {
4047 out.push(ch.to_ascii_lowercase());
4048 }
4049 }
4050 out
4051}
4052
4053fn pluralise_label(s: &str) -> String {
4058 let lower = s.to_lowercase();
4059 if lower.ends_with('s')
4060 || lower.ends_with('x')
4061 || lower.ends_with("ch")
4062 || lower.ends_with("sh")
4063 {
4064 format!("{lower}es")
4065 } else if lower.ends_with('y')
4066 && !lower.ends_with("ay")
4067 && !lower.ends_with("ey")
4068 && !lower.ends_with("iy")
4069 && !lower.ends_with("oy")
4070 && !lower.ends_with("uy")
4071 {
4072 format!("{}ies", &lower[..lower.len() - 1])
4073 } else {
4074 format!("{lower}s")
4075 }
4076}
4077
4078const DEFAULT_VISIBLE_COLUMNS: usize = 5;
4082
4083const NAME_LIKE_FIELDS: &[&str] = &["name", "full_name", "title", "email"];
4089
4090pub(crate) fn is_primary_column(f: &AdminField) -> bool {
4110 if f.name == "id" {
4112 return true;
4113 }
4114 if f.relation.is_some() && f.name.ends_with("_id") {
4117 return true;
4118 }
4119 if matches!(f.ty, FieldType::Bool) && f.name.starts_with("is_") {
4121 return true;
4122 }
4123 if matches!(f.name, "status" | "state" | "priority") {
4125 return true;
4126 }
4127 false
4129}
4130
4131pub(crate) fn default_list_columns<T: AdminModel>() -> Vec<&'static str> {
4148 let fields = T::FIELDS;
4149 let mut picked: Vec<&'static str> = Vec::with_capacity(DEFAULT_VISIBLE_COLUMNS);
4150
4151 let mut name_rule_used = false;
4156 for f in fields {
4157 if picked.len() >= DEFAULT_VISIBLE_COLUMNS {
4158 break;
4159 }
4160 let hits_name_rule = !name_rule_used && NAME_LIKE_FIELDS.contains(&f.name);
4161 if is_primary_column(f) || hits_name_rule {
4162 picked.push(f.name);
4163 if hits_name_rule {
4164 name_rule_used = true;
4165 }
4166 }
4167 }
4168
4169 picked
4170}
4171
4172fn field_label(f: &AdminField) -> String {
4177 let base = humanise(f.name);
4178 if f.relation.is_some() {
4179 base.strip_suffix(" Id").map(str::to_string).unwrap_or(base)
4180 } else {
4181 base
4182 }
4183}
4184
4185fn render_meta<T: AdminModel>(id: i64, item: &T) -> String {
4188 let mut items = vec![format!(
4189 r#"<div class="rio-meta-item">
4190<span class="rio-meta-label">ID</span>
4191<span class="rio-meta-value">#{id}</span>
4192</div>"#,
4193 )];
4194
4195 for f in T::FIELDS.iter() {
4196 if f.editable || f.name == "id" {
4197 continue;
4198 }
4199 let value = item.field_display(f.name).unwrap_or_default();
4200 let shown = if value.is_empty() {
4201 "—".to_string()
4202 } else {
4203 value
4204 };
4205 items.push(format!(
4206 r#"<div class="rio-meta-item">
4207<span class="rio-meta-label">{label}</span>
4208<span class="rio-meta-value">{value}</span>
4209</div>"#,
4210 label = escape_html(&humanise(f.name)),
4211 value = escape_html(&shown),
4212 ));
4213 }
4214
4215 format!(r#"<div class="rio-meta">{}</div>"#, items.join(""))
4216}
4217
4218fn render_field<T: AdminModel>(
4223 f: &AdminField,
4224 item: Option<&T>,
4225 placeholder: Option<&str>,
4226 form_options: &FormRelationOptions,
4227) -> String {
4228 let current = item
4229 .and_then(|i| i.field_display(f.name))
4230 .unwrap_or_default();
4231 let n = escape_html(f.name);
4232 let v = escape_html(¤t);
4233
4234 let required = if !f.nullable && !matches!(f.ty, FieldType::Bool) {
4235 " required"
4236 } else {
4237 ""
4238 };
4239
4240 let placeholder_attr = match placeholder {
4241 Some(p) if !p.is_empty() => format!(r#" placeholder="{}""#, escape_html(p)),
4242 _ => String::new(),
4243 };
4244
4245 if matches!(f.ty, FieldType::I32 | FieldType::I64) {
4251 if let Some(options) = form_options.get(f.name) {
4252 let none_opt = if f.nullable {
4253 r#"<option value="">— none —</option>"#
4254 } else {
4255 r#"<option value="" disabled selected>Select…</option>"#
4256 };
4257 let options_html: String = options
4258 .iter()
4259 .map(|(id, label)| {
4260 let selected = if current == id.to_string() {
4261 " selected"
4262 } else {
4263 ""
4264 };
4265 format!(
4266 r#"<option value="{id}"{selected}>{label}</option>"#,
4267 id = id,
4268 selected = selected,
4269 label = escape_html(label),
4270 )
4271 })
4272 .collect();
4273 let none_opt = if !current.is_empty() && !f.nullable {
4277 r#"<option value="" disabled>Select…</option>"#
4278 } else {
4279 none_opt
4280 };
4281 return format!(
4282 r#"<select class="rio-input rio-select" id="_{n}" name="{n}"{required}>{none}{opts}</select>"#,
4283 n = n,
4284 required = required,
4285 none = none_opt,
4286 opts = options_html,
4287 );
4288 }
4289 }
4290
4291 match f.ty {
4292 FieldType::Bool => format!(
4293 r#"<input class="rio-checkbox" id="_{n}" type="checkbox" name="{n}" {checked}>"#,
4294 checked = if current == "true" { "checked" } else { "" },
4295 ),
4296 FieldType::I32 | FieldType::I64 => {
4297 format!(
4298 r#"<input class="rio-input" id="_{n}" type="number" name="{n}" value="{v}"{required}{placeholder_attr}>"#
4299 )
4300 }
4301 FieldType::String => {
4302 format!(
4303 r#"<input class="rio-input" id="_{n}" type="text" name="{n}" value="{v}"{required}{placeholder_attr}>"#
4304 )
4305 }
4306 FieldType::DateTime => {
4307 format!(
4308 r#"<input class="rio-input" id="_{n}" type="datetime-local" name="{n}" value="{v}"{required}{placeholder_attr}>"#
4309 )
4310 }
4311 }
4312}
4313
4314fn delete_confirmation_response<T: AdminModel>(shell: Shell<'_>, id: i64, item: &T) -> Response {
4319 let singular = T::singular_name();
4320 let plural = T::DISPLAY_NAME;
4321 let admin_name = T::ADMIN_NAME;
4322
4323 let summary = T::FIELDS
4327 .iter()
4328 .find(|f| f.editable && matches!(f.ty, FieldType::String))
4329 .and_then(|f| item.field_display(f.name))
4330 .filter(|s| !s.is_empty())
4331 .unwrap_or_else(|| format!("#{id}"));
4332
4333 let csrf_hidden = csrf_input(shell.csrf);
4334
4335 let ctx = intelligence::context_global();
4339 let has_pii = T::FIELDS
4340 .iter()
4341 .any(|f| intelligence::field_ui_metadata(f, ctx).sensitive);
4342 let pii_banner = if has_pii {
4343 let note = if ctx.is_some_and(|c| c.requires_gdpr()) {
4344 "This record contains personal data (GDPR). Deletion is typically irreversible — verify you have the right to erase."
4345 } else {
4346 "This record contains fields flagged as personal data. Review before proceeding."
4347 };
4348 format!(
4349 r#"<div class="rio-alert rio-alert-error">{icon}<div><strong>Sensitive data.</strong> {note}</div></div>"#,
4350 icon = icon_shield_alert(),
4351 note = escape_html(note),
4352 )
4353 } else {
4354 String::new()
4355 };
4356
4357 let body = format!(
4358 r#"<div class="rio-card">
4359<div class="rio-card-body">
4360{pii_banner}
4361<div class="rio-alert rio-alert-warn">
4362{warn}
4363<div>
4364<strong>This action cannot be undone.</strong>
4365Deleting this record removes it permanently. Rows that reference it via a foreign key with <code>ON DELETE CASCADE</code> will be deleted too.
4366</div>
4367</div>
4368<p>You are about to delete <strong>{singular}</strong>:</p>
4369<div class="rio-meta">
4370<div class="rio-meta-item">
4371<span class="rio-meta-label">ID</span>
4372<span class="rio-meta-value">#{id}</span>
4373</div>
4374<div class="rio-meta-item">
4375<span class="rio-meta-label">Summary</span>
4376<span class="rio-meta-value">{summary}</span>
4377</div>
4378</div>
4379</div>
4380<div class="rio-form-footer">
4381<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back}<span>Back to {plural_lower}</span></a>
4382<div class="rio-footer-actions">
4383<a class="rio-btn" href="/admin/{name}/{id}/edit">Cancel</a>
4384<form class="rio-inline-form" method="post" action="/admin/{name}/{id}/delete">
4385{csrf}
4386<button class="rio-btn rio-btn-danger" type="submit">{trash}<span>Delete {singular}</span></button>
4387</form>
4388</div>
4389</div>
4390</div>"#,
4391 warn = icon_triangle_alert(),
4392 singular = escape_html(singular),
4393 id = id,
4394 summary = escape_html(&summary),
4395 name = escape_html(admin_name),
4396 back = icon_arrow_left(),
4397 plural_lower = escape_html(&plural.to_lowercase()),
4398 csrf = csrf_hidden,
4399 trash = icon_trash(),
4400 );
4401
4402 let plural_href = format!("/admin/{admin_name}");
4403 let crumbs: &[Crumb<'_>] = &[
4404 ("Admin", Some("/admin")),
4405 (plural, Some(&plural_href)),
4406 ("Delete", None),
4407 ];
4408
4409 render_shell_page(
4410 &shell,
4411 200,
4412 &format!("Delete {singular}"),
4413 &format!("Delete {singular}?"),
4414 Some("Confirm you want to remove this record."),
4415 crumbs,
4416 "",
4417 &body,
4418 )
4419}
4420
4421fn bulk_delete_confirmation_response<T: AdminModel>(
4426 shell: &Shell<'_>,
4427 items: &[(i64, String)],
4428) -> Response {
4429 let singular = T::singular_name();
4430 let plural = T::DISPLAY_NAME;
4431 let admin_name = T::ADMIN_NAME;
4432 let csrf_hidden = csrf_input(shell.csrf);
4433
4434 let count = items.len();
4435 let count_label = if count == 1 {
4436 format!("1 {}", singular.to_lowercase())
4437 } else {
4438 format!("{count} {}", plural.to_lowercase())
4439 };
4440
4441 let selected_csv: String = items
4444 .iter()
4445 .map(|(id, _)| id.to_string())
4446 .collect::<Vec<_>>()
4447 .join(",");
4448 let rows: String = items
4449 .iter()
4450 .map(|(id, primary)| {
4451 let label = if primary.is_empty() {
4452 format!("#{id}")
4453 } else {
4454 format!("#{id} · {primary}")
4455 };
4456 format!(
4457 r#"<li class="rio-bulk-item">{label}</li>"#,
4458 label = escape_html(&label),
4459 )
4460 })
4461 .collect();
4462
4463 let body = format!(
4464 r#"<div class="rio-card">
4465<div class="rio-card-body">
4466<div class="rio-alert rio-alert-warn">
4467{warn}
4468<div>
4469<strong>This action cannot be undone.</strong>
4470You are about to delete <strong>{count_label}</strong>. Each record removed here is logged individually in <a href="/admin/actions">Recent actions</a>.
4471</div>
4472</div>
4473<p>Review the list, then confirm:</p>
4474<ul class="rio-bulk-list">{rows}</ul>
4475</div>
4476<form method="post" action="/admin/{name}/bulk_action" class="rio-form-footer">
4477{csrf}
4478<input type="hidden" name="action" value="delete">
4479<input type="hidden" name="_selected" value="{selected}">
4480<input type="hidden" name="_confirm" value="yes">
4481<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back}<span>Cancel</span></a>
4482<div class="rio-footer-actions">
4483<button class="rio-btn rio-btn-danger" type="submit">{trash}<span>Yes, delete {count_label}</span></button>
4484</div>
4485</form>
4486</div>"#,
4487 warn = icon_triangle_alert(),
4488 count_label = escape_html(&count_label),
4489 rows = rows,
4490 name = escape_html(admin_name),
4491 csrf = csrf_hidden,
4492 selected = escape_html(&selected_csv),
4493 back = icon_arrow_left(),
4494 trash = icon_trash(),
4495 );
4496
4497 let plural_href = format!("/admin/{admin_name}");
4498 let crumbs: &[Crumb<'_>] = &[
4499 ("Admin", Some("/admin")),
4500 (plural, Some(&plural_href)),
4501 ("Delete selected", None),
4502 ];
4503
4504 render_shell_page(
4505 shell,
4506 200,
4507 &format!("Delete selected {}", plural.to_lowercase()),
4508 &format!("Delete selected {}?", plural.to_lowercase()),
4509 Some("Confirm you want to remove these records."),
4510 crumbs,
4511 "",
4512 &body,
4513 )
4514}
4515
4516fn new_request_id() -> String {
4521 use rand::RngCore as _;
4522 let mut buf = [0u8; 6];
4523 rand::rngs::OsRng.fill_bytes(&mut buf);
4524 let mut s = String::with_capacity(12);
4525 for b in buf {
4526 s.push_str(&format!("{b:02x}"));
4527 }
4528 s
4529}
4530
4531fn error_shell<'a>(
4534 entries: &'a [AdminEntry],
4535 email: Option<&'a str>,
4536 csrf: Option<&'a str>,
4537) -> Shell<'a> {
4538 Shell {
4539 entries,
4540 active: None,
4541 user_email: email,
4542 csrf,
4543 }
4544}
4545
4546fn admin_not_found_response(
4552 _entries: &[AdminEntry],
4553 _email: Option<&str>,
4554 csrf: Option<&str>,
4555) -> Response {
4556 let design = design::Design::global();
4557 let env = crate::admin::templating::env();
4558 let body = match env.get_template("auth/not_found.html").and_then(|tmpl| {
4559 tmpl.render(minijinja::context! {
4560 design => minijinja::context! {
4561 project_name => design.project_name.as_str(),
4562 logo_initial => design.logo_initial.as_str(),
4563 },
4564 csrf_token => csrf.unwrap_or(""),
4565 })
4566 }) {
4567 Ok(html) => html,
4568 Err(err) => {
4569 eprintln!("admin not-found template render failed: {err}");
4570 format!(
4571 "<!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>",
4572 p = escape_html(&design.project_name),
4573 )
4574 }
4575 };
4576 let resp = hyper::Response::builder()
4577 .status(404)
4578 .header("content-type", "text/html; charset=utf-8")
4579 .body(Full::new(Bytes::from(body)))
4580 .expect("valid response");
4581 with_admin_headers(resp)
4582}
4583
4584fn admin_server_error_response(
4585 entries: &[AdminEntry],
4586 email: Option<&str>,
4587 csrf: Option<&str>,
4588 request_id: &str,
4589) -> Response {
4590 let shell = error_shell(entries, email, csrf);
4591 let when = Utc::now().format("%Y-%m-%d %H:%M UTC").to_string();
4592 let body = format!(
4593 r#"<div class="rio-card">
4594<div class="rio-card-body">
4595<div class="rio-alert rio-alert-error">
4596{icon}
4597<div>
4598<strong>Something went wrong.</strong>
4599The admin could not complete your request. The detail has been logged server-side; the summary below is what to share when reporting.
4600</div>
4601</div>
4602<div class="rio-meta">
4603<div class="rio-meta-item">
4604<span class="rio-meta-label">Request ID</span>
4605<span class="rio-meta-value"><code>{rid}</code></span>
4606</div>
4607<div class="rio-meta-item">
4608<span class="rio-meta-label">Timestamp</span>
4609<span class="rio-meta-value">{when}</span>
4610</div>
4611</div>
4612<div class="rio-error-actions">
4613<a class="rio-btn" href="/admin">{back}<span>Back to dashboard</span></a>
4614</div>
4615</div>
4616</div>"#,
4617 icon = icon_triangle_alert(),
4618 rid = escape_html(request_id),
4619 when = escape_html(&when),
4620 back = icon_arrow_left(),
4621 );
4622 let crumbs: &[Crumb<'_>] = &[("Admin", Some("/admin")), ("Server error", None)];
4623 render_shell_page(
4624 &shell,
4625 500,
4626 "500 Server Error",
4627 "500 · Server error",
4628 Some("The admin could not complete your request."),
4629 crumbs,
4630 "",
4631 &body,
4632 )
4633}
4634
4635async fn admin_model_index_get(
4646 db: &Db,
4647 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4648 legacy_entries: &[AdminEntry],
4649 req: Request,
4650 params: crate::router::Params,
4651) -> Result<Response, Error> {
4652 if let Err(resp) = admin_guard(req.ctx()) {
4653 return Ok(resp);
4654 }
4655 let model_slug = params.get("model").unwrap_or("").to_string();
4656
4657 enum ResolvedModel {
4662 New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
4663 Legacy(crate::admin::layout::LegacyEntryModel),
4664 }
4665 let resolved = if let Some(model) = registry.get(&model_slug) {
4666 ResolvedModel::New(model)
4667 } else if let Some(entry) = legacy_entries
4668 .iter()
4669 .find(|e| !e.core && e.admin_name == model_slug)
4670 {
4671 ResolvedModel::Legacy(crate::admin::layout::LegacyEntryModel::new(entry))
4672 } else {
4673 return Err(Error::NotFound);
4674 };
4675 let q_map = req.query().into_map();
4676 let id = q_map.get("id").filter(|s| !s.is_empty()).cloned();
4677 let query = q_map
4678 .get("q")
4679 .map(|s| s.trim())
4680 .filter(|s| !s.is_empty())
4681 .map(String::from);
4682 let page = q_map
4683 .get("page")
4684 .and_then(|p| p.parse::<i64>().ok())
4685 .filter(|p| *p > 0)
4686 .unwrap_or(1);
4687 let sort = q_map.get("sort").filter(|s| !s.is_empty()).cloned();
4688 let dir = q_map.get("dir").filter(|s| !s.is_empty()).cloned();
4689 let filters: std::collections::HashMap<String, String> = q_map
4690 .iter()
4691 .filter(|(k, v)| {
4692 !v.is_empty()
4693 && k.as_str() != "q"
4694 && k.as_str() != "page"
4695 && k.as_str() != "id"
4696 && k.as_str() != "sort"
4697 && k.as_str() != "dir"
4698 && k.as_str() != "advanced"
4699 })
4700 .map(|(k, v)| (k.clone(), v.clone()))
4701 .collect();
4702 let _ = q_map
4703 .get("advanced")
4704 .map(|s| !s.is_empty())
4705 .unwrap_or(false);
4706 let _ = id;
4711 let identity = crate::auth::identity(req.ctx()).cloned();
4712 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
4713 let html = match &resolved {
4714 ResolvedModel::New(model) => {
4715 crate::admin::layout::list_render(
4716 db,
4717 registry,
4718 legacy_entries,
4719 &**model,
4720 None,
4721 query.as_deref(),
4722 page,
4723 &filters,
4724 sort.as_deref(),
4725 dir.as_deref(),
4726 identity.as_ref(),
4727 csrf.as_deref(),
4728 )
4729 .await
4730 }
4731 ResolvedModel::Legacy(model) => {
4732 let source = model.source_entry().clone();
4733 crate::admin::layout::list_render(
4734 db,
4735 registry,
4736 legacy_entries,
4737 model,
4738 Some(&source),
4739 query.as_deref(),
4740 page,
4741 &filters,
4742 sort.as_deref(),
4743 dir.as_deref(),
4744 identity.as_ref(),
4745 csrf.as_deref(),
4746 )
4747 .await
4748 }
4749 };
4750 Ok(with_admin_headers(crate::http::html(html)))
4751}
4752
4753async fn admin_model_form_get(
4759 db: &Db,
4760 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4761 legacy_entries: &[AdminEntry],
4762 req: Request,
4763 params: crate::router::Params,
4764 editing_id: Option<&str>,
4765) -> Result<Response, Error> {
4766 if let Err(resp) = admin_guard(req.ctx()) {
4767 return Ok(resp);
4768 }
4769 let model_slug = params.get("model").unwrap_or("").to_string();
4770
4771 enum ResolvedModel {
4772 New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
4773 Legacy(crate::admin::layout::LegacyEntryModel),
4774 }
4775 let resolved = if let Some(model) = registry.get(&model_slug) {
4776 ResolvedModel::New(model)
4777 } else if let Some(entry) = legacy_entries
4778 .iter()
4779 .find(|e| !e.core && e.admin_name == model_slug)
4780 {
4781 ResolvedModel::Legacy(crate::admin::layout::LegacyEntryModel::new(entry))
4782 } else {
4783 return Err(Error::NotFound);
4784 };
4785
4786 let identity = crate::auth::identity(req.ctx()).cloned();
4787 let csrf = ctx_csrf(req.ctx()).map(str::to_string);
4788 let html = match &resolved {
4789 ResolvedModel::New(model) => {
4790 crate::admin::layout::form_render(
4791 db,
4792 registry,
4793 legacy_entries,
4794 &**model,
4795 None,
4796 editing_id,
4797 identity.as_ref(),
4798 csrf.as_deref(),
4799 None,
4800 )
4801 .await
4802 }
4803 ResolvedModel::Legacy(model) => {
4804 let source = model.source_entry().clone();
4805 crate::admin::layout::form_render(
4806 db,
4807 registry,
4808 legacy_entries,
4809 model,
4810 Some(&source),
4811 editing_id,
4812 identity.as_ref(),
4813 csrf.as_deref(),
4814 None,
4815 )
4816 .await
4817 }
4818 };
4819 Ok(with_admin_headers(crate::http::html(html)))
4820}
4821
4822fn resolve_form_model(
4832 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4833 legacy_entries: &[AdminEntry],
4834 slug: &str,
4835) -> Result<FormResolvedModel, Error> {
4836 if let Some(model) = registry.get(slug) {
4837 return Ok(FormResolvedModel::New(model));
4838 }
4839 if let Some(entry) = legacy_entries
4840 .iter()
4841 .find(|e| !e.core && e.admin_name == slug)
4842 {
4843 return Ok(FormResolvedModel::Legacy(
4844 crate::admin::layout::LegacyEntryModel::new(entry),
4845 ));
4846 }
4847 Err(Error::NotFound)
4848}
4849
4850enum FormResolvedModel {
4851 New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
4852 Legacy(crate::admin::layout::LegacyEntryModel),
4853}
4854
4855impl FormResolvedModel {
4856 fn as_ui_model(&self) -> &dyn crate::admin::admin_form_bridge::AdminUiModel {
4857 match self {
4858 FormResolvedModel::New(m) => &**m,
4859 FormResolvedModel::Legacy(m) => m,
4860 }
4861 }
4862
4863 fn legacy_source(&self) -> Option<&AdminEntry> {
4868 match self {
4869 FormResolvedModel::New(_) => None,
4870 FormResolvedModel::Legacy(m) => Some(m.source_entry()),
4871 }
4872 }
4873}
4874
4875fn build_mutation_data(
4880 model: &dyn crate::admin::admin_form_bridge::AdminUiModel,
4881 form: &FormData,
4882) -> std::collections::HashMap<String, String> {
4883 let pk = model.primary_key();
4884 let mut out = std::collections::HashMap::new();
4885 for field in model.fields() {
4886 if field.name == pk || field.readonly {
4887 continue;
4888 }
4889 let value = form.get(field.name).unwrap_or("");
4890 out.insert(field.name.to_string(), value.to_string());
4891 }
4892 out
4893}
4894
4895async fn admin_model_create_post(
4896 db: &Db,
4897 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4898 legacy_entries: &[AdminEntry],
4899 req: Request,
4900 params: crate::router::Params,
4901) -> Result<Response, Error> {
4902 if let Err(resp) = admin_guard(req.ctx()) {
4903 return Ok(resp);
4904 }
4905 let model_slug = params.get("model").unwrap_or("").to_string();
4906 let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
4907
4908 let (_, body, ctx) = req.into_parts();
4909 let form = read_form_from_parts(body).await?;
4910 require_csrf(&ctx, &form)?;
4911
4912 let data = build_mutation_data(resolved.as_ui_model(), &form);
4913 match crate::admin::persistence::insert_record(db, resolved.as_ui_model().table_name(), &data)
4914 .await
4915 {
4916 Ok(_) => Ok(with_admin_headers(redirect(&format!(
4917 "/admin/{model_slug}"
4918 )))),
4919 Err(e) => {
4920 let identity = crate::auth::identity(&ctx).cloned();
4921 let csrf = ctx_csrf(&ctx).map(str::to_string);
4922 let error_msg = format!("Could not create: {e}");
4923 let source = resolved.legacy_source().cloned();
4924 let html = crate::admin::layout::form_render(
4925 db,
4926 registry,
4927 legacy_entries,
4928 resolved.as_ui_model(),
4929 source.as_ref(),
4930 None,
4931 identity.as_ref(),
4932 csrf.as_deref(),
4933 Some(&error_msg),
4934 )
4935 .await;
4936 Ok(with_admin_headers(crate::http::html(html)))
4937 }
4938 }
4939}
4940
4941async fn admin_model_update_post(
4942 db: &Db,
4943 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4944 legacy_entries: &[AdminEntry],
4945 req: Request,
4946 params: crate::router::Params,
4947) -> Result<Response, Error> {
4948 if let Err(resp) = admin_guard(req.ctx()) {
4949 return Ok(resp);
4950 }
4951 let model_slug = params.get("model").unwrap_or("").to_string();
4952 let id = params.get("id").unwrap_or("").to_string();
4953 if id.is_empty() {
4954 return Err(Error::BadRequest("missing id".into()));
4955 }
4956 let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
4957
4958 let (_, body, ctx) = req.into_parts();
4959 let form = read_form_from_parts(body).await?;
4960 require_csrf(&ctx, &form)?;
4961
4962 let data = build_mutation_data(resolved.as_ui_model(), &form);
4963 match crate::admin::persistence::update_record(
4964 db,
4965 resolved.as_ui_model().table_name(),
4966 &id,
4967 &data,
4968 )
4969 .await
4970 {
4971 Ok(_) => Ok(with_admin_headers(redirect(&format!(
4972 "/admin/{model_slug}"
4973 )))),
4974 Err(e) => {
4975 let identity = crate::auth::identity(&ctx).cloned();
4976 let csrf = ctx_csrf(&ctx).map(str::to_string);
4977 let error_msg = format!("Could not update: {e}");
4978 let source = resolved.legacy_source().cloned();
4979 let html = crate::admin::layout::form_render(
4980 db,
4981 registry,
4982 legacy_entries,
4983 resolved.as_ui_model(),
4984 source.as_ref(),
4985 Some(&id),
4986 identity.as_ref(),
4987 csrf.as_deref(),
4988 Some(&error_msg),
4989 )
4990 .await;
4991 Ok(with_admin_headers(crate::http::html(html)))
4992 }
4993 }
4994}
4995
4996async fn admin_model_delete_post(
4997 db: &Db,
4998 registry: &crate::admin::admin_form_bridge::AdminRegistry,
4999 legacy_entries: &[AdminEntry],
5000 req: Request,
5001 params: crate::router::Params,
5002) -> Result<Response, Error> {
5003 if let Err(resp) = admin_guard(req.ctx()) {
5004 return Ok(resp);
5005 }
5006 let model_slug = params.get("model").unwrap_or("").to_string();
5007 let id = params.get("id").unwrap_or("").to_string();
5008 if id.is_empty() {
5009 return Err(Error::BadRequest("missing id".into()));
5010 }
5011 let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
5012
5013 let (_, body, ctx) = req.into_parts();
5014 let form = read_form_from_parts(body).await?;
5015 require_csrf(&ctx, &form)?;
5016
5017 crate::admin::persistence::bulk_delete(
5018 db,
5019 resolved.as_ui_model().table_name(),
5020 std::slice::from_ref(&id),
5021 )
5022 .await?;
5023 Ok(with_admin_headers(redirect(&format!(
5024 "/admin/{model_slug}"
5025 ))))
5026}
5027
5028#[allow(clippy::result_large_err)]
5033fn admin_guard(ctx: &crate::context::Context) -> Result<(), Response> {
5034 match crate::auth::require_admin(ctx) {
5035 Ok(_) => Ok(()),
5036 Err(Error::Unauthorized) => Err(login_page(401, None, None)),
5037 Err(Error::Forbidden) => Err(forbidden_page(ctx_csrf(ctx))),
5038 Err(other) => Err(other.into_response()),
5039 }
5040}
5041
5042fn login_page(status: u16, email: Option<&str>, error: Option<&str>) -> Response {
5053 let design = design::Design::global();
5054 let env = crate::admin::templating::env();
5055 let body = match env.get_template("auth/login.html").and_then(|tmpl| {
5056 tmpl.render(minijinja::context! {
5057 design => minijinja::context! {
5058 project_name => design.project_name.as_str(),
5059 logo_initial => design.logo_initial.as_str(),
5060 },
5061 email => email.unwrap_or(""),
5062 error => error,
5063 })
5064 }) {
5065 Ok(html) => html,
5066 Err(err) => {
5067 eprintln!("admin login template render failed: {err}");
5068 login_page_fallback(&design.project_name, email, error)
5069 }
5070 };
5071
5072 let resp = hyper::Response::builder()
5073 .status(status)
5074 .header("content-type", "text/html; charset=utf-8")
5075 .body(Full::new(Bytes::from(body)))
5076 .expect("valid response");
5077 with_admin_headers(resp)
5078}
5079
5080fn login_page_fallback(project_name: &str, email: Option<&str>, error: Option<&str>) -> String {
5085 let project = escape_html(project_name);
5086 let email = email.map(escape_html).unwrap_or_default();
5087 let error_block = match error {
5088 Some(msg) => format!(r#"<p style="color:#b91c1c">{}</p>"#, escape_html(msg)),
5089 None => String::new(),
5090 };
5091 format!(
5092 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">
5093<h1>Sign in</h1><p>{project}</p>{error_block}
5094<form method="post" action="/admin/login">
5095<p><label>Email<br><input type="email" name="email" value="{email}" autofocus required></label></p>
5096<p><label>Password<br><input type="password" name="password" required></label></p>
5097<p><button type="submit">Sign in</button></p>
5098</form></body></html>"#,
5099 )
5100}
5101
5102fn forbidden_page(csrf: Option<&str>) -> Response {
5106 let design = design::Design::global();
5107 let env = crate::admin::templating::env();
5108 let body = match env.get_template("auth/forbidden.html").and_then(|tmpl| {
5109 tmpl.render(minijinja::context! {
5110 design => minijinja::context! {
5111 project_name => design.project_name.as_str(),
5112 logo_initial => design.logo_initial.as_str(),
5113 },
5114 csrf_token => csrf.unwrap_or(""),
5115 })
5116 }) {
5117 Ok(html) => html,
5118 Err(err) => {
5119 eprintln!("admin forbidden template render failed: {err}");
5120 forbidden_page_fallback(&design.project_name, csrf)
5121 }
5122 };
5123 let resp = hyper::Response::builder()
5124 .status(403)
5125 .header("content-type", "text/html; charset=utf-8")
5126 .body(Full::new(Bytes::from(body)))
5127 .expect("valid response");
5128 with_admin_headers(resp)
5129}
5130
5131fn forbidden_page_fallback(project_name: &str, csrf: Option<&str>) -> String {
5135 let project = escape_html(project_name);
5136 let csrf_input_html = match csrf {
5137 Some(token) if !token.is_empty() => format!(
5138 r#"<input type="hidden" name="_csrf" value="{}">"#,
5139 escape_html(token)
5140 ),
5141 _ => String::new(),
5142 };
5143 format!(
5144 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">
5145<p>403 Forbidden</p><h1>You're signed in, but you don't have admin access.</h1>
5146<form method="post" action="/admin/logout">{csrf_input_html}<button type="submit">Sign out</button></form>
5147</body></html>"#,
5148 )
5149}
5150
5151fn logout_confirmation_response(signed_in: bool, csrf: Option<&str>) -> Response {
5176 let d = design::Design::global();
5177
5178 let theme_style = format!(
5179 "\n:root {{\n --rio-primary: {p};\n --rio-accent: {a};\n}}\n",
5180 p = escape_css_color(&d.primary_color),
5181 a = escape_css_color(&d.accent_color),
5182 );
5183
5184 let card_body = if signed_in {
5185 let csrf_hidden = csrf_input(csrf);
5186 format!(
5187 r#"<h1 class="rio-auth-title">Sign out</h1>
5188<p class="rio-auth-subtitle">You're about to sign out of the admin.</p>
5189<form method="post" action="/admin/logout">
5190{csrf}
5191<button class="rio-btn rio-btn-primary rio-btn-block" type="submit">Sign out</button>
5192</form>
5193<p class="rio-auth-footer"><a href="/admin">Cancel and return to the admin</a></p>"#,
5194 csrf = csrf_hidden,
5195 )
5196 } else {
5197 String::from(
5198 r#"<h1 class="rio-auth-title">You have signed out</h1>
5199<p class="rio-auth-subtitle">Thanks for your time. Sessions are already revoked server-side.</p>
5200<a class="rio-btn rio-btn-primary rio-btn-block" href="/admin">Sign in again</a>"#,
5201 )
5202 };
5203
5204 let body = format!(
5205 r#"<!doctype html>
5206<html lang="en">
5207<head>
5208<meta charset="utf-8">
5209<meta name="viewport" content="width=device-width, initial-scale=1">
5210<title>Sign out · {project}</title>
5211<link rel="stylesheet" href="/admin/assets/admin.css?v={css_ver}">
5212<link rel="icon" type="image/svg+xml" href="/admin/assets/favicon.svg">
5213<style>{theme}</style>
5214</head>
5215<body>
5216<div class="rio-auth-shell">
5217<div class="rio-auth-card">
5218<div class="rio-auth-logo">
5219<span class="rio-brand-mark">{logo}</span>
5220<span class="rio-brand-meta">
5221<span class="rio-brand-name">{project}</span>
5222<span class="rio-brand-label">Admin</span>
5223</span>
5224</div>
5225{card_body}
5226</div>
5227</div>
5228</body>
5229</html>"#,
5230 project = escape_html(&d.project_name),
5231 theme = theme_style,
5232 logo = escape_html(&d.logo_initial),
5233 card_body = card_body,
5234 css_ver = ADMIN_CSS_VER,
5235 );
5236
5237 let resp = hyper::Response::builder()
5238 .status(200)
5239 .header("content-type", "text/html; charset=utf-8")
5240 .body(Full::new(Bytes::from(body)))
5241 .expect("valid response");
5242 with_admin_headers(resp)
5243}
5244
5245fn object_history_response<T: AdminModel>(
5255 shell: Shell<'_>,
5256 id: i64,
5257 item: &T,
5258 actions: &[audit::AdminAction],
5259) -> Response {
5260 let plural = T::DISPLAY_NAME;
5261 let singular = T::singular_name();
5262 let admin_name = T::ADMIN_NAME;
5263
5264 let summary = T::FIELDS
5265 .iter()
5266 .find(|f| f.editable && matches!(f.ty, FieldType::String))
5267 .and_then(|f| item.field_display(f.name))
5268 .filter(|s| !s.is_empty())
5269 .unwrap_or_else(|| format!("#{id}"));
5270
5271 let inner = if actions.is_empty() {
5272 format!(
5273 r#"<div class="rio-empty">
5274<div class="rio-empty-icon">{icon}</div>
5275<h3>No change history yet</h3>
5276<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>
5277</div>"#,
5278 icon = icon_inbox(),
5279 )
5280 } else {
5281 render_actions_timeline(actions, false)
5282 };
5283
5284 let body = format!(
5285 r#"<div class="rio-card">
5286<div class="rio-card-header">
5287<div>
5288<h2 class="rio-card-title">Change history — {singular_hdr} {summary}</h2>
5289<p class="rio-card-subtitle">Every add / change / delete that happened to this record, newest first.</p>
5290</div>
5291<a class="rio-btn" href="/admin/{name}/{id}/edit">Back to record</a>
5292</div>
5293{inner}
5294</div>"#,
5295 singular_hdr = escape_html(singular),
5296 summary = escape_html(&summary),
5297 name = escape_html(admin_name),
5298 id = id,
5299 inner = inner,
5300 );
5301
5302 let plural_href = format!("/admin/{admin_name}");
5303 let edit_href = format!("/admin/{admin_name}/{id}/edit");
5304 let crumbs: &[Crumb<'_>] = &[
5305 ("Admin", Some("/admin")),
5306 (plural, Some(&plural_href)),
5307 (singular, Some(&edit_href)),
5308 ("History", None),
5309 ];
5310
5311 render_shell_page(
5312 &shell,
5313 200,
5314 &format!("History — {singular} {summary}"),
5315 "Change history",
5316 Some("Every add / change / delete that happened to this record."),
5317 crumbs,
5318 "",
5319 &body,
5320 )
5321}
5322
5323#[allow(clippy::too_many_arguments)]
5345async fn suggestion_review_response(
5346 db: &Db,
5347 registry: &crate::admin::admin_form_bridge::AdminRegistry,
5348 legacy_entries: &[AdminEntry],
5349 identity: Option<&crate::auth::Identity>,
5350 csrf: Option<&str>,
5351 admin_name: &str,
5352 field: &str,
5353 error: Option<&str>,
5354) -> Response {
5355 let ctx = intelligence::context_global();
5356 let effective = entry_builder::entries_effective(legacy_entries);
5357 let Some(suggestion) =
5358 suggestions::find_suggestion_from_entries(&effective, ctx, admin_name, field)
5359 else {
5360 return admin_not_found_response(legacy_entries, None, csrf);
5361 };
5362
5363 let plan_result = match run_planner(&suggestion.prompt, ctx) {
5364 Ok(pr) => pr,
5365 Err(msg) => {
5366 return suggestion_error_response(
5367 db,
5368 registry,
5369 legacy_entries,
5370 identity,
5371 csrf,
5372 &suggestion,
5373 &msg,
5374 )
5375 .await;
5376 }
5377 };
5378 let review = match crate::ai::review_plan(
5379 plan_result.schema_ref(),
5380 &plan_result.plan_result.plan,
5381 ctx,
5382 ) {
5383 Ok(r) => r,
5384 Err(e) => {
5385 return suggestion_error_response(
5386 db,
5387 registry,
5388 legacy_entries,
5389 identity,
5390 csrf,
5391 &suggestion,
5392 &format!("review layer refused: {e}"),
5393 )
5394 .await;
5395 }
5396 };
5397
5398 let can_apply = matches!(review.validation, crate::ai::ValidationOutcome::Valid)
5399 && review.risk != crate::ai::RiskLevel::Critical;
5400
5401 let step_descriptions: Vec<String> = plan_result
5402 .plan_result
5403 .plan
5404 .steps
5405 .iter()
5406 .map(|p| match p {
5407 crate::ai::Primitive::AddField(a) => format!(
5408 "+ Add field <code>{}</code> (<code>{}</code>{}) to <code>{}</code>",
5409 escape_html(&a.field.name),
5410 escape_html(&a.field.ty),
5411 if a.field.nullable { ", nullable" } else { "" },
5412 escape_html(&a.model),
5413 ),
5414 other => escape_html(&format!("{other:?}")),
5415 })
5416 .collect();
5417
5418 let schema_diff_html =
5419 render_schema_diff(plan_result.schema_ref(), &plan_result.plan_result.plan);
5420
5421 let (risk_label, risk_class) = match review.risk {
5422 crate::ai::RiskLevel::Low => ("Low", "success"),
5423 crate::ai::RiskLevel::Medium => ("Medium", "warning"),
5424 crate::ai::RiskLevel::High => ("High", "danger"),
5425 crate::ai::RiskLevel::Critical => ("Critical", "danger"),
5426 };
5427
5428 let (validation_ok, validation_message) = match &review.validation {
5429 crate::ai::ValidationOutcome::Valid => (true, None),
5430 crate::ai::ValidationOutcome::Invalid { step, reason } => (
5431 false,
5432 Some(format!(
5433 "Plan fails at step {step}: {reason}. Regenerate the schema or adjust the plan before applying."
5434 )),
5435 ),
5436 };
5437
5438 let confidence_class = match suggestion.confidence.as_str() {
5439 "High" => "success",
5440 "Medium" => "warning",
5441 _ => "secondary",
5442 };
5443
5444 let view = crate::admin::layout::SuggestionReviewView {
5445 model: suggestion.model_display.clone(),
5446 field: suggestion.field.clone(),
5447 industry: ctx
5448 .and_then(|c| c.industry.as_deref())
5449 .unwrap_or("")
5450 .to_string(),
5451 confidence_label: suggestion.confidence.as_str().to_string(),
5452 confidence_class: confidence_class.to_string(),
5453 apply_url: suggestion.url_path(),
5454 can_apply,
5455 step_descriptions,
5456 schema_diff_html,
5457 explanation: plan_result.plan_result.explanation.clone(),
5458 risk_label: risk_label.to_string(),
5459 risk_class: risk_class.to_string(),
5460 adds_fields: review.impact.adds_fields as u32,
5461 destructive: review.impact.destructive,
5462 validation_ok,
5463 validation_message,
5464 warnings: review.warnings.clone(),
5465 error: error.map(str::to_string),
5466 };
5467
5468 let html = crate::admin::layout::suggestion_review_render(
5469 db,
5470 registry,
5471 legacy_entries,
5472 identity,
5473 csrf,
5474 view,
5475 )
5476 .await;
5477 with_admin_headers(crate::http::html(html))
5478}
5479
5480#[allow(clippy::too_many_arguments)]
5487async fn suggestion_apply_response(
5488 db: &Db,
5489 registry: &crate::admin::admin_form_bridge::AdminRegistry,
5490 legacy_entries: &[AdminEntry],
5491 identity: Option<&crate::auth::Identity>,
5492 csrf: Option<&str>,
5493 admin_name: &str,
5494 field: &str,
5495) -> Response {
5496 let ctx = intelligence::context_global();
5497 let effective = entry_builder::entries_effective(legacy_entries);
5498 let Some(suggestion) =
5499 suggestions::find_suggestion_from_entries(&effective, ctx, admin_name, field)
5500 else {
5501 return admin_not_found_response(legacy_entries, None, csrf);
5502 };
5503 let plan_result = match run_planner(&suggestion.prompt, ctx) {
5504 Ok(pr) => pr,
5505 Err(msg) => {
5506 return suggestion_review_response(
5507 db,
5508 registry,
5509 legacy_entries,
5510 identity,
5511 csrf,
5512 admin_name,
5513 field,
5514 Some(&msg),
5515 )
5516 .await;
5517 }
5518 };
5519 let doc = match crate::ai::build_plan_document(
5520 plan_result.schema_ref(),
5521 &suggestion.prompt,
5522 &plan_result.plan_result,
5523 ctx,
5524 ) {
5525 Ok(d) => d,
5526 Err(e) => {
5527 return suggestion_review_response(
5528 db,
5529 registry,
5530 legacy_entries,
5531 identity,
5532 csrf,
5533 admin_name,
5534 field,
5535 Some(&format!("plan document rejected: {e}")),
5536 )
5537 .await;
5538 }
5539 };
5540 if doc.risk == crate::ai::RiskLevel::Critical {
5541 return suggestion_review_response(
5542 db,
5543 registry,
5544 legacy_entries,
5545 identity,
5546 csrf,
5547 admin_name,
5548 field,
5549 Some("Plan risk is Critical — the safe executor refuses to apply it."),
5550 )
5551 .await;
5552 }
5553 let options = crate::ai::ExecuteOptions::default();
5554 let result =
5555 match crate::ai::execute_plan_document(std::path::Path::new("."), &doc, &options, ctx) {
5556 Ok(r) => r,
5557 Err(e) => {
5558 return suggestion_review_response(
5559 db,
5560 registry,
5561 legacy_entries,
5562 identity,
5563 csrf,
5564 admin_name,
5565 field,
5566 Some(&format!("executor refused: {e}")),
5567 )
5568 .await;
5569 }
5570 };
5571
5572 schema_cache::refresh_best_effort();
5573
5574 let change_lines: Vec<String> = doc.plan.steps.iter().map(describe_applied_step).collect();
5575
5576 let files: Vec<crate::admin::layout::AppliedFileView> = result
5577 .generated_files
5578 .iter()
5579 .map(|f| {
5580 let kind = if f.ends_with(".sql") {
5581 "Created migration"
5582 } else if f.ends_with(".rs") {
5583 "Updated"
5584 } else {
5585 "Wrote"
5586 };
5587 crate::admin::layout::AppliedFileView {
5588 kind: kind.to_string(),
5589 path: f.clone(),
5590 }
5591 })
5592 .collect();
5593
5594 let applied = crate::admin::layout::SuggestionAppliedView {
5595 change_lines,
5596 files,
5597 };
5598 let html = crate::admin::layout::suggestion_applied_render(
5599 db,
5600 registry,
5601 legacy_entries,
5602 identity,
5603 csrf,
5604 applied,
5605 )
5606 .await;
5607 with_admin_headers(crate::http::html(html))
5608}
5609
5610#[allow(clippy::too_many_arguments)]
5618async fn suggestion_error_response(
5619 db: &Db,
5620 registry: &crate::admin::admin_form_bridge::AdminRegistry,
5621 legacy_entries: &[AdminEntry],
5622 identity: Option<&crate::auth::Identity>,
5623 csrf: Option<&str>,
5624 suggestion: &suggestions::Suggestion,
5625 msg: &str,
5626) -> Response {
5627 let ctx = intelligence::context_global();
5628 let confidence_class = match suggestion.confidence.as_str() {
5629 "High" => "success",
5630 "Medium" => "warning",
5631 _ => "secondary",
5632 };
5633 let view = crate::admin::layout::SuggestionReviewView {
5634 model: suggestion.model_display.clone(),
5635 field: suggestion.field.clone(),
5636 industry: ctx
5637 .and_then(|c| c.industry.as_deref())
5638 .unwrap_or("")
5639 .to_string(),
5640 confidence_label: suggestion.confidence.as_str().to_string(),
5641 confidence_class: confidence_class.to_string(),
5642 apply_url: suggestion.url_path(),
5643 can_apply: false,
5644 step_descriptions: Vec::new(),
5645 schema_diff_html: String::new(),
5646 explanation: String::new(),
5647 risk_label: "?".into(),
5648 risk_class: "secondary".into(),
5649 adds_fields: 0,
5650 destructive: false,
5651 validation_ok: false,
5652 validation_message: None,
5653 warnings: Vec::new(),
5654 error: Some(msg.to_string()),
5655 };
5656 let html = crate::admin::layout::suggestion_review_render(
5657 db,
5658 registry,
5659 legacy_entries,
5660 identity,
5661 csrf,
5662 view,
5663 )
5664 .await;
5665 with_admin_headers(crate::http::html(html))
5666}
5667
5668fn render_schema_diff(schema: &crate::schema::Schema, plan: &crate::ai::Plan) -> String {
5676 use std::collections::BTreeSet;
5677
5678 let mut touched: Vec<String> = Vec::new();
5680 for step in &plan.steps {
5681 if let crate::ai::Primitive::AddField(a) = step {
5682 if !touched.contains(&a.model) {
5683 touched.push(a.model.clone());
5684 }
5685 }
5686 }
5687 if touched.is_empty() {
5688 return String::new();
5689 }
5690
5691 let mut out = String::new();
5692 for model_name in &touched {
5693 let Some(model) = schema.models.iter().find(|m| &m.name == model_name) else {
5694 continue;
5695 };
5696 let before: Vec<(String, String)> = model
5698 .fields
5699 .iter()
5700 .map(|f| {
5701 let ty = if f.nullable {
5702 format!("Option<{}>", f.ty)
5703 } else {
5704 f.ty.clone()
5705 };
5706 (f.name.clone(), ty)
5707 })
5708 .collect();
5709 let before_names: BTreeSet<&str> = before.iter().map(|(n, _)| n.as_str()).collect();
5710 let mut added: Vec<(String, String)> = Vec::new();
5711 for step in &plan.steps {
5712 if let crate::ai::Primitive::AddField(a) = step {
5713 if a.model == *model_name && !before_names.contains(a.field.name.as_str()) {
5714 let ty = if a.field.nullable {
5715 format!("Option<{}>", a.field.ty)
5716 } else {
5717 a.field.ty.clone()
5718 };
5719 added.push((a.field.name.clone(), ty));
5720 }
5721 }
5722 }
5723 out.push_str(&format!(
5724 r#"<div class="rio-schema-diff"><h3>Model <code>{}</code></h3><pre>"#,
5725 escape_html(model_name),
5726 ));
5727 for (name, ty) in &before {
5728 out.push_str(&format!(" {}: {}\n", escape_html(name), escape_html(ty),));
5729 }
5730 for (name, ty) in &added {
5731 out.push_str(&format!(
5732 "<span class=\"rio-schema-diff-add\">+ {}: {}</span>\n",
5733 escape_html(name),
5734 escape_html(ty),
5735 ));
5736 }
5737 out.push_str("</pre></div>");
5738 }
5739 out
5740}
5741
5742fn describe_applied_step(p: &crate::ai::Primitive) -> String {
5746 match p {
5747 crate::ai::Primitive::AddField(a) => format!(
5748 "Added field <code>{}</code> (<code>{}</code>{}) to <code>{}</code>",
5749 escape_html(&a.field.name),
5750 escape_html(&a.field.ty),
5751 if a.field.nullable { ", nullable" } else { "" },
5752 escape_html(&a.model),
5753 ),
5754 crate::ai::Primitive::RenameField(r) => format!(
5755 "Renamed <code>{}.{}</code> to <code>{}</code>",
5756 escape_html(&r.model),
5757 escape_html(&r.from),
5758 escape_html(&r.to),
5759 ),
5760 crate::ai::Primitive::RenameModel(r) => format!(
5761 "Renamed model <code>{}</code> to <code>{}</code>",
5762 escape_html(&r.from),
5763 escape_html(&r.to),
5764 ),
5765 other => escape_html(&format!("{:?}", other)),
5766 }
5767}
5768
5769struct PlannerCallResult {
5773 plan_result: crate::ai::PlanResult,
5774 schema: crate::schema::Schema,
5775}
5776impl PlannerCallResult {
5777 fn schema_ref(&self) -> &crate::schema::Schema {
5778 &self.schema
5779 }
5780}
5781
5782fn run_planner(
5783 prompt: &str,
5784 context: Option<&crate::ai::ContextConfig>,
5785) -> Result<PlannerCallResult, String> {
5786 let schema_path = std::path::Path::new("rustio.schema.json");
5791 let schema_json = std::fs::read_to_string(schema_path)
5792 .map_err(|e| format!("could not read rustio.schema.json: {e}"))?;
5793 let schema = crate::schema::Schema::parse(&schema_json)
5794 .map_err(|e| format!("rustio.schema.json parse error: {e}"))?;
5795 let plan_result = crate::ai::generate_plan(
5796 &schema,
5797 context,
5798 crate::ai::PlanRequest::new(prompt.to_string()),
5799 )
5800 .map_err(|e| format!("planner refused: {e}"))?;
5801 Ok(PlannerCallResult {
5802 plan_result,
5803 schema,
5804 })
5805}
5806
5807fn render_actions_timeline(actions: &[audit::AdminAction], show_object_link: bool) -> String {
5818 if actions.is_empty() {
5819 return String::new();
5820 }
5821 let rows: String = actions
5822 .iter()
5823 .map(|a| {
5824 let action = audit::ActionType::parse(&a.action_type);
5825 let (pill_class, label) = match action {
5826 Some(at) => (at.pill_class(), at.label()),
5827 None => ("rio-pill rio-pill-slate", "Action"),
5828 };
5829 let when = a.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
5830 let who = a
5831 .user_email
5832 .clone()
5833 .unwrap_or_else(|| format!("user #{}", a.user_id));
5834 let ip = match &a.ip_address {
5835 Some(ip) if !ip.is_empty() => {
5836 format!(r#"<span class="rio-audit-ip">{}</span>"#, escape_html(ip))
5837 }
5838 _ => String::new(),
5839 };
5840 let object_link = if show_object_link {
5841 format!(
5842 r#"<a class="rio-audit-object" href="/admin/{name}/{id}/history">{name} #{id}</a>"#,
5843 name = escape_html(&a.model_name),
5844 id = a.object_id,
5845 )
5846 } else {
5847 String::new()
5848 };
5849 format!(
5850 r#"<li class="rio-audit-item">
5851<div class="rio-audit-head">
5852<span class="{pill}">{label}</span>
5853{object_link}
5854<span class="rio-audit-when">{when}</span>
5855</div>
5856<p class="rio-audit-summary">{summary}</p>
5857<div class="rio-audit-meta">
5858<span class="rio-audit-who">{who}</span>
5859{ip}
5860</div>
5861</li>"#,
5862 pill = pill_class,
5863 label = label,
5864 object_link = object_link,
5865 when = escape_html(&when),
5866 summary = escape_html(&a.summary),
5867 who = escape_html(&who),
5868 ip = ip,
5869 )
5870 })
5871 .collect();
5872 format!(r#"<ul class="rio-audit-timeline">{rows}</ul>"#)
5873}
5874
5875fn build_session_cookie(name: &str, token: &str, max_age: i64) -> String {
5880 build_session_cookie_impl(name, token, max_age, crate::auth::in_production())
5881}
5882
5883fn build_session_cookie_impl(name: &str, token: &str, max_age: i64, secure: bool) -> String {
5884 let secure = if secure { "; Secure" } else { "" };
5885 format!("{name}={token}; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age={max_age}")
5886}
5887
5888async fn handle_login(req: Request, db: &crate::orm::Db) -> Result<Response, Error> {
5893 use crate::auth;
5894
5895 let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
5896
5897 let form = read_form(req).await?;
5898 let email = form.get("email").unwrap_or("").trim().to_string();
5899 let password = form.get("password").unwrap_or("").to_string();
5900
5901 if email.is_empty() || password.is_empty() {
5902 return Ok(login_page(
5903 400,
5904 Some(&email),
5905 Some("Email and password are both required."),
5906 ));
5907 }
5908
5909 let email_key = auth::normalise_email(&email);
5910 let rate_key = auth::LoginRateLimiter::compose_key(&email_key, peer_ip.as_deref());
5911 if let Err(remaining) = auth::LoginRateLimiter::global().check(&rate_key) {
5912 return Ok(login_page(
5913 429,
5914 Some(&email),
5915 Some(&format!(
5916 "Too many failed attempts. Try again in {}s.",
5917 remaining.as_secs().max(1),
5918 )),
5919 ));
5920 }
5921
5922 let generic = "Invalid email or password.";
5923
5924 let user = auth::user::find_by_email(db, &email).await?;
5925 let valid = match &user {
5926 Some(u) => auth::password::verify(&password, &u.password_hash),
5927 None => {
5928 let _ = auth::password::verify(&password, auth::dummy_password_hash());
5929 false
5930 }
5931 };
5932
5933 if !valid {
5934 auth::LoginRateLimiter::global().record_failure(&rate_key);
5935 return Ok(login_page(401, Some(&email), Some(generic)));
5936 }
5937
5938 let user = user.expect("valid credentials imply a found user");
5939 if !user.is_active {
5940 return Ok(login_page(
5941 403,
5942 Some(&email),
5943 Some("This account is inactive. Contact an administrator."),
5944 ));
5945 }
5946
5947 auth::LoginRateLimiter::global().record_success(&rate_key);
5948 let _ = auth::session::sweep_expired(db).await;
5949
5950 let session = auth::session::create(db, user.id).await?;
5951
5952 let mut resp = redirect("/admin");
5953 let max_age = auth::SESSION_TTL_DAYS * 24 * 3600;
5954 crate::http::set_cookie(
5955 &mut resp,
5956 &build_session_cookie(auth::SESSION_COOKIE, &session.id, max_age),
5957 );
5958 Ok(with_admin_headers(resp))
5959}
5960
5961async fn handle_logout(req: Request, db: &crate::orm::Db) -> Result<Response, Error> {
5962 use crate::auth;
5963
5964 let cookie_token = req.cookie(auth::SESSION_COOKIE);
5965 let (_, body, ctx) = req.into_parts();
5966 let form = read_form_from_parts(body).await?;
5967 require_csrf(&ctx, &form)?;
5968
5969 if let Some(token) = cookie_token {
5970 let _ = auth::session::delete(db, &token).await;
5971 }
5972
5973 let mut resp = redirect("/admin/logout");
5978 crate::http::set_cookie(
5979 &mut resp,
5980 &build_session_cookie(auth::SESSION_COOKIE, "", 0),
5981 );
5982 Ok(with_admin_headers(resp))
5983}
5984
5985async fn handle_password_change_post(
6000 req: Request,
6001 db: &crate::orm::Db,
6002 registry: &crate::admin::admin_form_bridge::AdminRegistry,
6003 legacy_entries: &[AdminEntry],
6004) -> Result<Response, Error> {
6005 use crate::auth;
6006
6007 let (_, body, ctx) = req.into_parts();
6008 let form = read_form_from_parts(body).await?;
6009 require_csrf(&ctx, &form)?;
6010
6011 let user_id = match ctx.get::<auth::Identity>() {
6014 Some(i) => i.user_id,
6015 None => return Ok(login_page(401, None, None)),
6016 };
6017 let identity = crate::auth::identity(&ctx).cloned();
6018 let csrf = ctx_csrf(&ctx).map(str::to_string);
6019
6020 let old = form.get("old_password").unwrap_or("").to_string();
6021 let new1 = form.get("new_password1").unwrap_or("").to_string();
6022 let new2 = form.get("new_password2").unwrap_or("").to_string();
6023
6024 async fn render_err(
6026 db: &crate::orm::Db,
6027 registry: &crate::admin::admin_form_bridge::AdminRegistry,
6028 legacy_entries: &[AdminEntry],
6029 identity: Option<&crate::auth::Identity>,
6030 csrf: Option<&str>,
6031 msg: &str,
6032 ) -> Response {
6033 let html = crate::admin::layout::password_change_render(
6034 db,
6035 registry,
6036 legacy_entries,
6037 identity,
6038 csrf,
6039 Some(msg),
6040 )
6041 .await;
6042 with_admin_headers(crate::http::html(html))
6043 }
6044
6045 if old.is_empty() || new1.is_empty() || new2.is_empty() {
6046 return Ok(render_err(
6047 db,
6048 registry,
6049 legacy_entries,
6050 identity.as_ref(),
6051 csrf.as_deref(),
6052 "All three fields are required.",
6053 )
6054 .await);
6055 }
6056 if new1 != new2 {
6057 return Ok(render_err(
6058 db,
6059 registry,
6060 legacy_entries,
6061 identity.as_ref(),
6062 csrf.as_deref(),
6063 "The two new password fields did not match. Try again.",
6064 )
6065 .await);
6066 }
6067 if new1.len() < 8 {
6068 return Ok(render_err(
6069 db,
6070 registry,
6071 legacy_entries,
6072 identity.as_ref(),
6073 csrf.as_deref(),
6074 "Your new password must be at least 8 characters.",
6075 )
6076 .await);
6077 }
6078
6079 let user = match auth::user::find_by_id(db, user_id).await? {
6082 Some(u) => u,
6083 None => return Ok(login_page(401, None, None)),
6084 };
6085 if !auth::password::verify(&old, &user.password_hash) {
6086 return Ok(render_err(
6087 db,
6088 registry,
6089 legacy_entries,
6090 identity.as_ref(),
6091 csrf.as_deref(),
6092 "Your old password was entered incorrectly. Please try again.",
6093 )
6094 .await);
6095 }
6096
6097 auth::user::set_password(db, user.id, &new1).await?;
6101 let session = auth::session::create(db, user.id).await?;
6102 let max_age = auth::SESSION_TTL_DAYS * 24 * 3600;
6103 let mut resp = redirect("/admin/password_change/done");
6104 crate::http::set_cookie(
6105 &mut resp,
6106 &build_session_cookie(auth::SESSION_COOKIE, &session.id, max_age),
6107 );
6108 Ok(with_admin_headers(resp))
6109}
6110
6111pub fn parse_datetime_local(raw: &str) -> Result<DateTime<Utc>, String> {
6118 if raw.is_empty() {
6119 return Err(String::from("date-time value is empty"));
6120 }
6121 if raw.trim_matches(|c: char| c.is_ascii_whitespace()) != raw {
6122 return Err(format!("`{raw}` has surrounding whitespace"));
6123 }
6124 if raw.ends_with('Z') || raw.contains('+') || (raw.matches('-').count() > 2) {
6125 return Err(format!(
6126 "`{raw}` looks like a timezoned date-time; expected YYYY-MM-DDTHH:MM"
6127 ));
6128 }
6129
6130 let parsed = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")
6131 .or_else(|_| NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
6132 .map_err(|_| format!("`{raw}` is not a valid date-time"))?;
6133 match Utc.from_local_datetime(&parsed) {
6134 chrono::LocalResult::Single(dt) => Ok(dt),
6135 _ => Err(format!("`{raw}` could not be interpreted as UTC")),
6136 }
6137}
6138
6139fn escape_html(s: &str) -> String {
6140 let mut out = String::with_capacity(s.len());
6141 for ch in s.chars() {
6142 match ch {
6143 '&' => out.push_str("&"),
6144 '<' => out.push_str("<"),
6145 '>' => out.push_str(">"),
6146 '"' => out.push_str("""),
6147 '\'' => out.push_str("'"),
6148 c => out.push(c),
6149 }
6150 }
6151 out
6152}
6153
6154#[cfg(test)]
6155mod tests {
6156 use super::*;
6157 use chrono::Timelike;
6158
6159 #[test]
6166 fn session_cookie_dev_has_no_secure_flag() {
6167 let c = build_session_cookie_impl("rustio_session", "TOK", 600, false);
6168 assert_eq!(
6169 c,
6170 "rustio_session=TOK; Path=/; HttpOnly; SameSite=Strict; Max-Age=600"
6171 );
6172 assert!(!c.contains("Secure"));
6173 }
6174
6175 #[test]
6176 fn session_cookie_production_has_secure_flag() {
6177 let c = build_session_cookie_impl("rustio_session", "TOK", 600, true);
6178 assert_eq!(
6179 c,
6180 "rustio_session=TOK; Path=/; HttpOnly; SameSite=Strict; Secure; Max-Age=600"
6181 );
6182 }
6183
6184 #[test]
6185 fn session_cookie_expiration_shape_is_stable() {
6186 let c = build_session_cookie_impl("rustio_session", "", 0, true);
6187 assert!(c.contains("rustio_session=; "));
6188 assert!(c.contains("HttpOnly"));
6189 assert!(c.contains("SameSite=Strict"));
6190 assert!(c.contains("Secure"));
6191 assert!(c.contains("Max-Age=0"));
6192 }
6193
6194 #[test]
6195 fn escape_html_escapes_dangerous_chars() {
6196 assert_eq!(
6197 escape_html("<script>alert(\"xss\")</script>"),
6198 "<script>alert("xss")</script>"
6199 );
6200 assert_eq!(escape_html("a & b"), "a & b");
6201 assert_eq!(escape_html("it's"), "it's");
6202 }
6203
6204 #[test]
6207 fn escape_css_color_accepts_hex_tokens() {
6208 assert_eq!(escape_css_color("#0f172a"), "#0f172a");
6209 assert_eq!(escape_css_color("#4f46e5"), "#4f46e5");
6210 }
6211
6212 #[test]
6213 fn escape_css_color_rejects_injection_attempts() {
6214 assert_eq!(escape_css_color("red; } body { display:none"), "#0f172a");
6217 assert_eq!(escape_css_color("}</style><script>"), "#0f172a");
6218 assert_eq!(escape_css_color("red\\0A "), "#0f172a");
6219 }
6220
6221 #[test]
6224 fn parse_datetime_local_accepts_minute_precision() {
6225 let dt = parse_datetime_local("2026-04-18T10:12").unwrap();
6226 assert_eq!(dt.to_rfc3339(), "2026-04-18T10:12:00+00:00");
6227 assert!(dt.to_rfc3339().ends_with("+00:00"));
6228 }
6229
6230 #[test]
6231 fn parse_datetime_local_accepts_second_precision() {
6232 let dt = parse_datetime_local("2026-04-18T10:12:33").unwrap();
6233 assert_eq!(dt.to_rfc3339(), "2026-04-18T10:12:33+00:00");
6234 }
6235
6236 #[test]
6237 fn parse_datetime_local_rejects_empty_string() {
6238 assert!(parse_datetime_local("").is_err());
6239 }
6240
6241 #[test]
6242 fn parse_datetime_local_rejects_free_text() {
6243 assert!(parse_datetime_local("tomorrow at noon").is_err());
6244 }
6245
6246 #[test]
6247 fn parse_datetime_local_rejects_partial_date() {
6248 assert!(parse_datetime_local("2026-04-18").is_err());
6249 }
6250
6251 #[test]
6252 fn parse_datetime_local_rejects_out_of_range_date() {
6253 assert!(parse_datetime_local("2026-13-01T00:00").is_err());
6254 assert!(parse_datetime_local("2026-04-31T00:00").is_err());
6255 }
6256
6257 #[test]
6258 fn parse_datetime_local_rejects_out_of_range_time() {
6259 assert!(parse_datetime_local("2026-04-18T25:00").is_err());
6260 assert!(parse_datetime_local("2026-04-18T10:99").is_err());
6261 }
6262
6263 #[test]
6264 fn parse_datetime_local_rejects_surrounding_whitespace() {
6265 assert!(parse_datetime_local(" 2026-04-18T10:12").is_err());
6266 assert!(parse_datetime_local("2026-04-18T10:12 ").is_err());
6267 }
6268
6269 #[test]
6270 fn parse_datetime_local_rejects_timezone_suffix() {
6271 assert!(parse_datetime_local("2026-04-18T10:12Z").is_err());
6272 assert!(parse_datetime_local("2026-04-18T10:12:00+00:00").is_err());
6273 }
6274
6275 struct Widgety;
6278 impl crate::orm::Model for Widgety {
6279 const TABLE: &'static str = "widgety";
6280 const COLUMNS: &'static [&'static str] = &["id"];
6281 const INSERT_COLUMNS: &'static [&'static str] = &[];
6282 fn id(&self) -> i64 {
6283 0
6284 }
6285 fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6286 unimplemented!()
6287 }
6288 fn insert_values(&self) -> Vec<crate::orm::Value> {
6289 Vec::new()
6290 }
6291 }
6292 impl AdminModel for Widgety {
6293 const ADMIN_NAME: &'static str = "widgety";
6294 const DISPLAY_NAME: &'static str = "Widgety";
6295 const FIELDS: &'static [AdminField] = &[];
6296 fn field_display(&self, name: &str) -> Option<String> {
6297 match name {
6298 "filled" => Some(String::from("2026-04-18T10:12")),
6299 "empty" => Some(String::new()),
6300 _ => None,
6301 }
6302 }
6303 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6304 unimplemented!()
6305 }
6306 }
6307
6308 fn string_field(name: &'static str, nullable: bool) -> AdminField {
6309 AdminField {
6310 name,
6311 ty: FieldType::String,
6312 editable: true,
6313 nullable,
6314 relation: None,
6315 }
6316 }
6317
6318 fn datetime_field(name: &'static str, nullable: bool) -> AdminField {
6319 AdminField {
6320 name,
6321 ty: FieldType::DateTime,
6322 editable: true,
6323 nullable,
6324 relation: None,
6325 }
6326 }
6327
6328 #[test]
6329 fn nullable_string_field_omits_required_attribute() {
6330 let f = string_field("note", true);
6331 let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6332 assert!(!html.contains("required"), "html was: {html}");
6333 }
6334
6335 #[test]
6336 fn non_nullable_string_field_marks_required() {
6337 let f = string_field("title", false);
6338 let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6339 assert!(html.contains("required"), "html was: {html}");
6340 }
6341
6342 #[test]
6343 fn bool_field_never_marks_required() {
6344 let f = AdminField {
6345 name: "flag",
6346 ty: FieldType::Bool,
6347 editable: true,
6348 nullable: false,
6349 relation: None,
6350 };
6351 let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6352 assert!(!html.contains("required"), "html was: {html}");
6353 }
6354
6355 #[test]
6356 fn datetime_field_uses_datetime_local_input() {
6357 let f = datetime_field("starts_at", false);
6358 let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6359 assert!(
6360 html.contains(r#"type="datetime-local""#),
6361 "html was: {html}"
6362 );
6363 }
6364
6365 #[test]
6366 fn datetime_field_renders_existing_value() {
6367 let f = datetime_field("filled", true);
6368 let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
6369 assert!(
6370 html.contains(r#"value="2026-04-18T10:12""#),
6371 "html was: {html}"
6372 );
6373 }
6374
6375 #[test]
6376 fn nullable_field_with_none_value_does_not_panic() {
6377 let f = string_field("empty", true);
6378 let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
6379 assert!(html.contains(r#"value="""#));
6380 assert!(!html.contains("required"));
6381 }
6382
6383 #[test]
6384 fn field_display_returning_none_renders_empty_value() {
6385 let f = string_field("unknown_field", false);
6386 let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
6387 assert!(html.contains(r#"value="""#));
6388 }
6389
6390 #[test]
6391 fn parse_datetime_local_enforces_utc_for_every_valid_input() {
6392 let inputs = [
6393 "2000-01-01T00:00",
6394 "2026-04-18T10:12",
6395 "2026-04-18T10:12:33",
6396 "2099-12-31T23:59",
6397 ];
6398 for raw in inputs {
6399 let dt = parse_datetime_local(raw).unwrap_or_else(|e| panic!("`{raw}`: {e}"));
6400 assert!(
6401 dt.to_rfc3339().ends_with("+00:00"),
6402 "non-UTC offset in output for `{raw}`: {}",
6403 dt.to_rfc3339(),
6404 );
6405 assert!(
6406 dt.nanosecond() == 0,
6407 "unexpected sub-second part for `{raw}`"
6408 );
6409 }
6410 }
6411
6412 #[test]
6415 fn humanise_converts_snake_case_to_title_case() {
6416 assert_eq!(humanise("title"), "Title");
6417 assert_eq!(humanise("is_active"), "Is Active");
6418 assert_eq!(humanise("created_at"), "Created At");
6419 assert_eq!(humanise("assigned_to"), "Assigned To");
6420 }
6421
6422 fn make_field(
6428 name: &'static str,
6429 ty: FieldType,
6430 relation: Option<AdminRelation>,
6431 ) -> AdminField {
6432 AdminField {
6433 name,
6434 ty,
6435 editable: true,
6436 nullable: false,
6437 relation,
6438 }
6439 }
6440
6441 fn fk(target: &'static str) -> AdminRelation {
6442 AdminRelation {
6443 kind: crate::schema::RelationKind::BelongsTo,
6444 model: target,
6445 display_field: None,
6446 }
6447 }
6448
6449 #[test]
6450 fn primary_rule_1_id_always_primary() {
6451 assert!(is_primary_column(&make_field("id", FieldType::I64, None)));
6452 }
6453
6454 #[test]
6455 fn primary_rule_4_fk_ending_in_id() {
6456 assert!(is_primary_column(&make_field(
6457 "department_id",
6458 FieldType::I64,
6459 Some(fk("Department")),
6460 )));
6461 assert!(!is_primary_column(&make_field(
6463 "department_id",
6464 FieldType::I64,
6465 None,
6466 )));
6467 assert!(!is_primary_column(&make_field(
6469 "department",
6470 FieldType::I64,
6471 Some(fk("Department")),
6472 )));
6473 }
6474
6475 #[test]
6476 fn primary_rule_5_is_prefix_bool() {
6477 assert!(is_primary_column(&make_field(
6478 "is_active",
6479 FieldType::Bool,
6480 None,
6481 )));
6482 assert!(is_primary_column(&make_field(
6483 "is_admin",
6484 FieldType::Bool,
6485 None,
6486 )));
6487 assert!(!is_primary_column(&make_field(
6489 "active",
6490 FieldType::Bool,
6491 None,
6492 )));
6493 assert!(!is_primary_column(&make_field(
6495 "is_active",
6496 FieldType::String,
6497 None,
6498 )));
6499 }
6500
6501 #[test]
6502 fn primary_rule_6_status_state_priority() {
6503 assert!(is_primary_column(&make_field(
6504 "status",
6505 FieldType::String,
6506 None,
6507 )));
6508 assert!(is_primary_column(&make_field(
6509 "state",
6510 FieldType::String,
6511 None,
6512 )));
6513 assert!(is_primary_column(&make_field(
6514 "priority",
6515 FieldType::I32,
6516 None,
6517 )));
6518 assert!(!is_primary_column(&make_field(
6520 "priorities",
6521 FieldType::I32,
6522 None,
6523 )));
6524 }
6525
6526 #[test]
6527 fn primary_rule_7_plain_fields_not_primary() {
6528 assert!(!is_primary_column(&make_field(
6529 "specialty",
6530 FieldType::String,
6531 None,
6532 )));
6533 assert!(!is_primary_column(&make_field(
6534 "license_no",
6535 FieldType::String,
6536 None,
6537 )));
6538 assert!(!is_primary_column(&make_field(
6539 "years_experience",
6540 FieldType::I32,
6541 None,
6542 )));
6543 assert!(!is_primary_column(&make_field(
6544 "created_at",
6545 FieldType::DateTime,
6546 None,
6547 )));
6548 }
6549
6550 struct DoctorFixture;
6557 impl crate::orm::Model for DoctorFixture {
6558 const TABLE: &'static str = "doctors";
6559 const COLUMNS: &'static [&'static str] = &[
6560 "id",
6561 "full_name",
6562 "specialty",
6563 "department_id",
6564 "license_no",
6565 "email",
6566 "phone",
6567 "years_experience",
6568 "is_active",
6569 "created_at",
6570 ];
6571 const INSERT_COLUMNS: &'static [&'static str] = &[];
6572 fn id(&self) -> i64 {
6573 0
6574 }
6575 fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6576 unimplemented!()
6577 }
6578 fn insert_values(&self) -> Vec<crate::orm::Value> {
6579 Vec::new()
6580 }
6581 }
6582 impl AdminModel for DoctorFixture {
6583 const ADMIN_NAME: &'static str = "doctors";
6584 const DISPLAY_NAME: &'static str = "Doctors";
6585 const FIELDS: &'static [AdminField] = &[
6586 AdminField {
6587 name: "id",
6588 ty: FieldType::I64,
6589 editable: false,
6590 nullable: false,
6591 relation: None,
6592 },
6593 AdminField {
6594 name: "full_name",
6595 ty: FieldType::String,
6596 editable: true,
6597 nullable: false,
6598 relation: None,
6599 },
6600 AdminField {
6601 name: "specialty",
6602 ty: FieldType::String,
6603 editable: true,
6604 nullable: false,
6605 relation: None,
6606 },
6607 AdminField {
6608 name: "department_id",
6609 ty: FieldType::I64,
6610 editable: true,
6611 nullable: false,
6612 relation: Some(AdminRelation {
6613 kind: crate::schema::RelationKind::BelongsTo,
6614 model: "Department",
6615 display_field: None,
6616 }),
6617 },
6618 AdminField {
6619 name: "license_no",
6620 ty: FieldType::String,
6621 editable: true,
6622 nullable: false,
6623 relation: None,
6624 },
6625 AdminField {
6626 name: "email",
6627 ty: FieldType::String,
6628 editable: true,
6629 nullable: false,
6630 relation: None,
6631 },
6632 AdminField {
6633 name: "phone",
6634 ty: FieldType::String,
6635 editable: true,
6636 nullable: false,
6637 relation: None,
6638 },
6639 AdminField {
6640 name: "years_experience",
6641 ty: FieldType::I32,
6642 editable: true,
6643 nullable: false,
6644 relation: None,
6645 },
6646 AdminField {
6647 name: "is_active",
6648 ty: FieldType::Bool,
6649 editable: true,
6650 nullable: false,
6651 relation: None,
6652 },
6653 AdminField {
6654 name: "created_at",
6655 ty: FieldType::DateTime,
6656 editable: false,
6657 nullable: false,
6658 relation: None,
6659 },
6660 ];
6661 fn singular_name() -> &'static str {
6662 "Doctor"
6663 }
6664 fn field_display(&self, _: &str) -> Option<String> {
6665 None
6666 }
6667 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6668 unimplemented!()
6669 }
6670 }
6671
6672 #[test]
6673 fn default_columns_doctor_yields_four_rule_matches() {
6674 let cols = default_list_columns::<DoctorFixture>();
6681 assert_eq!(cols, vec!["id", "full_name", "department_id", "is_active",]);
6682 }
6683
6684 #[test]
6685 fn default_columns_returns_fewer_than_five_when_rules_match_fewer() {
6686 struct Tiny;
6690 impl crate::orm::Model for Tiny {
6691 const TABLE: &'static str = "tinies";
6692 const COLUMNS: &'static [&'static str] = &["id", "name", "is_active"];
6693 const INSERT_COLUMNS: &'static [&'static str] = &[];
6694 fn id(&self) -> i64 {
6695 0
6696 }
6697 fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6698 unimplemented!()
6699 }
6700 fn insert_values(&self) -> Vec<crate::orm::Value> {
6701 Vec::new()
6702 }
6703 }
6704 impl AdminModel for Tiny {
6705 const ADMIN_NAME: &'static str = "tinies";
6706 const DISPLAY_NAME: &'static str = "Tinies";
6707 const FIELDS: &'static [AdminField] = &[
6708 AdminField {
6709 name: "id",
6710 ty: FieldType::I64,
6711 editable: false,
6712 nullable: false,
6713 relation: None,
6714 },
6715 AdminField {
6716 name: "name",
6717 ty: FieldType::String,
6718 editable: true,
6719 nullable: false,
6720 relation: None,
6721 },
6722 AdminField {
6723 name: "is_active",
6724 ty: FieldType::Bool,
6725 editable: true,
6726 nullable: false,
6727 relation: None,
6728 },
6729 ];
6730 fn singular_name() -> &'static str {
6731 "Tiny"
6732 }
6733 fn field_display(&self, _: &str) -> Option<String> {
6734 None
6735 }
6736 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6737 unimplemented!()
6738 }
6739 }
6740 let cols = default_list_columns::<Tiny>();
6741 assert_eq!(cols, vec!["id", "name", "is_active"]);
6742 }
6743
6744 #[test]
6745 fn default_list_columns_caps_at_five() {
6746 struct Stuffed;
6749 impl crate::orm::Model for Stuffed {
6750 const TABLE: &'static str = "stuffed";
6751 const COLUMNS: &'static [&'static str] = &[
6752 "id",
6753 "name",
6754 "status",
6755 "state",
6756 "priority",
6757 "is_active",
6758 "is_admin",
6759 ];
6760 const INSERT_COLUMNS: &'static [&'static str] = &[];
6761 fn id(&self) -> i64 {
6762 0
6763 }
6764 fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6765 unimplemented!()
6766 }
6767 fn insert_values(&self) -> Vec<crate::orm::Value> {
6768 Vec::new()
6769 }
6770 }
6771 impl AdminModel for Stuffed {
6772 const ADMIN_NAME: &'static str = "stuffed";
6773 const DISPLAY_NAME: &'static str = "Stuffed";
6774 const FIELDS: &'static [AdminField] = &[
6775 AdminField {
6776 name: "id",
6777 ty: FieldType::I64,
6778 editable: false,
6779 nullable: false,
6780 relation: None,
6781 },
6782 AdminField {
6783 name: "name",
6784 ty: FieldType::String,
6785 editable: true,
6786 nullable: false,
6787 relation: None,
6788 },
6789 AdminField {
6790 name: "status",
6791 ty: FieldType::String,
6792 editable: true,
6793 nullable: false,
6794 relation: None,
6795 },
6796 AdminField {
6797 name: "state",
6798 ty: FieldType::String,
6799 editable: true,
6800 nullable: false,
6801 relation: None,
6802 },
6803 AdminField {
6804 name: "priority",
6805 ty: FieldType::I32,
6806 editable: true,
6807 nullable: false,
6808 relation: None,
6809 },
6810 AdminField {
6811 name: "is_active",
6812 ty: FieldType::Bool,
6813 editable: true,
6814 nullable: false,
6815 relation: None,
6816 },
6817 AdminField {
6818 name: "is_admin",
6819 ty: FieldType::Bool,
6820 editable: true,
6821 nullable: false,
6822 relation: None,
6823 },
6824 ];
6825 fn singular_name() -> &'static str {
6826 "Stuffed"
6827 }
6828 fn field_display(&self, _: &str) -> Option<String> {
6829 None
6830 }
6831 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6832 unimplemented!()
6833 }
6834 }
6835 let cols = default_list_columns::<Stuffed>();
6836 assert_eq!(cols.len(), 5, "cap must hold at 5");
6837 assert_eq!(cols, vec!["id", "name", "status", "state", "priority"]);
6839 }
6840
6841 #[test]
6842 fn default_list_columns_rule_3_first_name_like_wins() {
6843 struct TwoNames;
6849 impl crate::orm::Model for TwoNames {
6850 const TABLE: &'static str = "two_names";
6851 const COLUMNS: &'static [&'static str] = &["id", "full_name", "email"];
6852 const INSERT_COLUMNS: &'static [&'static str] = &[];
6853 fn id(&self) -> i64 {
6854 0
6855 }
6856 fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6857 unimplemented!()
6858 }
6859 fn insert_values(&self) -> Vec<crate::orm::Value> {
6860 Vec::new()
6861 }
6862 }
6863 impl AdminModel for TwoNames {
6864 const ADMIN_NAME: &'static str = "two_names";
6865 const DISPLAY_NAME: &'static str = "TwoNames";
6866 const FIELDS: &'static [AdminField] = &[
6867 AdminField {
6868 name: "id",
6869 ty: FieldType::I64,
6870 editable: false,
6871 nullable: false,
6872 relation: None,
6873 },
6874 AdminField {
6875 name: "full_name",
6876 ty: FieldType::String,
6877 editable: true,
6878 nullable: false,
6879 relation: None,
6880 },
6881 AdminField {
6882 name: "email",
6883 ty: FieldType::String,
6884 editable: true,
6885 nullable: false,
6886 relation: None,
6887 },
6888 ];
6889 fn singular_name() -> &'static str {
6890 "TwoName"
6891 }
6892 fn field_display(&self, _: &str) -> Option<String> {
6893 None
6894 }
6895 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6896 unimplemented!()
6897 }
6898 }
6899 let cols = default_list_columns::<TwoNames>();
6900 assert_eq!(cols, vec!["id", "full_name"]);
6907 }
6908}