1mod audit;
18pub mod bootstrap;
19mod bulk;
20pub mod checklist;
21mod clientip;
22pub mod descriptor;
23mod error;
24mod export;
25pub mod field;
26pub mod form;
27pub mod html;
28pub mod http_cache;
29mod icons;
30pub mod import;
31pub(crate) mod keyed;
32pub mod list;
33pub mod medialib;
34pub mod nav;
35pub(crate) mod palette;
36pub mod persist;
37pub mod picker;
38pub mod plugins;
39pub mod relation;
40pub(crate) mod requestlog;
41mod roles;
42mod routemap;
43pub mod routes;
44mod session;
45pub mod settings;
46mod sql;
47pub mod ui;
48mod upload;
49mod users;
50
51pub use axum;
59
60pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
61pub use error::AdminError;
62pub use session::{FlashLevel, SessionHandle};
63pub use upload::VerifiedUpload;
64
65use std::collections::HashMap;
66use std::sync::{Arc, RwLock};
67
68use askama::Template;
69use axum::body::Body;
70use axum::extract::{Path, Query, Request, State};
71use axum::http::{header, HeaderMap};
72use axum::middleware::{self, Next};
73use axum::response::{Html, IntoResponse, Redirect, Response};
74use axum::routing::{any, get, post};
75use axum::{Extension, Form, Router};
76use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
77use chrono_tz::{Tz, TZ_VARIANTS};
78use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
79use laterite_core::{t, CatalogStore, Db, Text, Translator};
80use serde::{Deserialize, Serialize};
81
82pub trait AdminRegistry {
88 fn add_resource(&mut self, resource: Resource);
90 fn add_permission(&mut self, permission: Permission);
92 fn add_settings(&mut self, item: settings::SettingsItem);
94 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
96 fn add_persister(&mut self, persister: persist::PersisterReg);
98 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
100 fn add_screen(&mut self, screen: routes::ScreenReg);
103 fn add_public_route(&mut self, route: routes::PublicRouteReg);
106 fn add_nav(&mut self, item: nav::NavItem);
108 fn add_resource_hook(&mut self, hook: ResourceHook);
110}
111
112impl AdminRegistry for laterite_core::Registry {
113 fn add_resource(&mut self, resource: Resource) {
114 self.add(resource);
115 }
116 fn add_permission(&mut self, permission: Permission) {
117 self.add(permission);
118 }
119 fn add_settings(&mut self, item: settings::SettingsItem) {
120 self.add(item);
121 }
122 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
123 self.add(source);
124 }
125 fn add_screen(&mut self, screen: routes::ScreenReg) {
126 self.add(screen);
127 }
128 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
129 self.add(route);
130 }
131 fn add_nav(&mut self, item: nav::NavItem) {
132 self.add(item);
133 }
134 fn add_resource_hook(&mut self, hook: ResourceHook) {
135 self.add(hook);
136 }
137 fn add_persister(&mut self, persister: persist::PersisterReg) {
138 self.add(persister);
139 }
140 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
141 self.add(field_type);
142 }
143}
144
145#[derive(Clone)]
152pub struct ResourceHook {
153 pub target: String,
154 apply: Arc<dyn Fn(Resource) -> Resource + Send + Sync>,
155}
156
157impl ResourceHook {
158 pub fn new(
159 target: impl Into<String>,
160 apply: impl Fn(Resource) -> Resource + Send + Sync + 'static,
161 ) -> Self {
162 Self {
163 target: target.into(),
164 apply: Arc::new(apply),
165 }
166 }
167
168 pub fn apply(&self, resource: Resource) -> Resource {
170 (self.apply)(resource)
171 }
172}
173
174impl std::fmt::Debug for ResourceHook {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 write!(f, "ResourceHook({})", self.target)
177 }
178}
179
180pub(crate) const SESSION_COOKIE: &str = "laterite_session";
181const REMEMBER_COOKIE: &str = "laterite_remember";
185
186pub(crate) const ICON_SPRITE: &str = "icons.svg";
189
190#[derive(Clone)]
192pub(crate) struct AdminState {
193 auth: AuthService,
194 db: Db,
195 nav: Arc<nav::NavTree>,
196 settings: Arc<Vec<settings::SettingsItem>>,
197 permissions: Arc<Vec<Permission>>,
198 admin_path: Arc<str>,
202 cookie_path: Arc<str>,
206 storage_dir: Arc<str>,
208 signing_key: Arc<Vec<u8>>,
210 secure_cookie: bool,
211 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
213 icons: Arc<laterite_core::icons::IconSet>,
215 sprite_url: Arc<str>,
217 origin: Arc<str>,
219 timezone: Tz,
220 default_locale: Arc<str>,
224 catalogs: Arc<CatalogStore>,
227 app_name: String,
230 brand_cache: Arc<RwLock<Option<String>>>,
234 asset_urls: Arc<AssetUrls>,
236 field_types: Arc<field::FieldRegistry>,
239 column_types: Arc<list::ColumnRegistry>,
242 plugin_defined: Arc<laterite_core::Registry>,
245 assets: Arc<AssetRegistry>,
247 pickers: Arc<picker::PickerRegistry>,
250 palette: Arc<Vec<palette::RecordSource>>,
253 overrides: Arc<dyn field::OverrideResolver>,
257}
258
259impl AdminState {
260 #[cfg(test)]
261 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
262 Self {
263 auth,
264 db,
265 nav: Arc::new(nav::NavTree::default()),
266 settings: Arc::new(Vec::new()),
267 permissions: Arc::new(builtin_permissions()),
268 trusted_proxies: Arc::new(Vec::new()),
269 icons: Arc::new(laterite_core::icons::IconSet::new()),
270 sprite_url: Arc::from(""),
271 admin_path: Arc::from("/admin"),
272 cookie_path: Arc::from("/admin"),
273 storage_dir: Arc::from("storage"),
274 signing_key: Arc::new(random_key()),
275 secure_cookie: false,
276 origin: Arc::from(""),
277 timezone: Tz::UTC,
278 default_locale: Arc::from("en"),
279 catalogs: Arc::new(CatalogStore::default()),
280 app_name: "Laterite".to_string(),
281 brand_cache: Arc::new(RwLock::new(None)),
282 field_types: Arc::new(field::builtin_registry()),
283 column_types: Arc::new(list::builtin_column_registry()),
284 plugin_defined: Arc::new(laterite_core::Registry::new()),
285 assets: Arc::new(builtin_assets()),
286 asset_urls: Arc::new(asset_urls(&builtin_assets())),
287 pickers: Arc::new(picker::PickerRegistry::new()),
288 palette: Arc::new(Vec::new()),
289 overrides: Arc::new(field::NoOverrides),
290 }
291 }
292
293 async fn brand(&self) -> String {
297 {
298 let cached = self.brand_cache.read().unwrap().clone();
299 if let Some(name) = cached {
300 return name;
301 }
302 }
303 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
304 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
305 _ => self.app_name.clone(),
306 };
307 *self.brand_cache.write().unwrap() = Some(resolved.clone());
308 resolved
309 }
310
311 fn invalidate_brand(&self) {
313 *self.brand_cache.write().unwrap() = None;
314 }
315}
316
317#[derive(Clone)]
323#[non_exhaustive]
324pub struct AdminConfig {
325 pub secure_cookie: bool,
328 pub trusted_proxies: Vec<String>,
332 pub timezone: String,
336 pub locale: String,
340 pub app_name: String,
344 pub path: String,
348 pub origin: String,
353 pub storage_dir: String,
358 pub secret: Vec<u8>,
362}
363
364impl Default for AdminConfig {
365 fn default() -> Self {
366 Self {
367 secure_cookie: false,
368 trusted_proxies: Vec::new(),
369 timezone: "UTC".to_string(),
370 locale: "en".to_string(),
371 app_name: "Laterite".to_string(),
372 path: "/admin".to_string(),
373 origin: String::new(),
374 storage_dir: "storage".to_string(),
375 secret: random_key(),
376 }
377 }
378}
379
380pub(crate) fn random_key() -> Vec<u8> {
382 use rand::RngCore;
383 let mut key = vec![0u8; 32];
384 rand::thread_rng().fill_bytes(&mut key);
385 key
386}
387
388#[derive(Clone)]
397pub struct Shell {
398 pub(crate) base: String,
402 brand: String,
406 nav: Vec<NavView>,
407 full_name: String,
408 initial: String,
409 tz: Tz,
413 i18n: Translator,
417 sidebar: Vec<settings::CategoryView>,
421 crumbs: Vec<CrumbView>,
424 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
426 pub(crate) sprite_url: Arc<str>,
429 site_url: String,
433 asset_urls: Arc<AssetUrls>,
437 pub(crate) csrf_token: String,
441 pub(crate) flash: Vec<FlashLine>,
445 pub(crate) assets: Vec<PageAsset>,
449}
450
451#[derive(Template)]
453#[template(path = "page.html")]
454struct PageTemplate {
455 title: String,
456 back: Option<(String, String)>,
458 actions: Option<html::Markup>,
460 body: html::Markup,
461 shell: Shell,
462}
463
464pub struct PageHead {
475 title: Text,
476 back: Option<(Text, String)>,
477 actions: Option<html::Markup>,
478}
479
480impl PageHead {
481 pub fn new(title: impl Into<Text>) -> Self {
482 Self {
483 title: title.into(),
484 back: None,
485 actions: None,
486 }
487 }
488
489 pub fn back(mut self, label: impl Into<Text>, href: impl Into<String>) -> Self {
491 self.back = Some((label.into(), href.into()));
492 self
493 }
494
495 pub fn actions(mut self, actions: html::Markup) -> Self {
498 self.actions = Some(actions);
499 self
500 }
501}
502
503#[derive(Clone)]
505pub(crate) struct PageAsset {
506 pub(crate) url: String,
507 pub(crate) css: bool,
508}
509
510#[derive(Clone)]
513pub(crate) struct FlashLine {
514 pub(crate) level: session::FlashLevel,
515 pub(crate) text: String,
516 pub(crate) sticky: bool,
518}
519
520impl Shell {
521 #[allow(clippy::too_many_arguments)]
522 fn new(
523 base: &str,
524 brand: String,
525 nav: &[nav::NavNode],
526 user: &AuthenticatedUser,
527 default_tz: Tz,
528 sidebar: Vec<settings::CategoryView>,
529 active_nav: Option<&str>,
530 csrf_token: String,
531 flash: Vec<session::Flash>,
532 i18n: Translator,
533 asset_urls: Arc<AssetUrls>,
534 site_url: String,
535 icon_set: Arc<laterite_core::icons::IconSet>,
536 sprite_url: Arc<str>,
537 ) -> Self {
538 let full_name = user.user.full_name();
539 let initial = full_name
540 .chars()
541 .next()
542 .map(|c| c.to_uppercase().to_string())
543 .unwrap_or_else(|| "?".to_string());
544 let flash = flash
547 .into_iter()
548 .map(|f| FlashLine {
549 sticky: f.sticky,
550 level: f.level,
551 text: i18n.t(&f.text),
552 })
553 .collect();
554 let nav: Vec<NavView> = nav
555 .iter()
556 .map(|n| NavView {
557 key: n.item.key.clone(),
558 label: i18n.t(&n.item.label),
559 path: nav::tab_path(n).unwrap_or_default().to_string(),
560 active: active_nav == Some(n.item.key.as_str()),
561 icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
562 })
563 .collect();
564 let mut crumbs: Vec<CrumbView> = nav
565 .iter()
566 .filter(|n: &&NavView| n.active)
567 .map(|n| CrumbView {
568 label: n.label.clone(),
569 href: n.path.clone(),
570 })
571 .collect();
572 if let Some(entry) = sidebar
573 .iter()
574 .flat_map(|g| g.items.iter())
575 .find(|i| i.active)
576 {
577 crumbs.push(CrumbView {
578 label: entry.label.clone(),
579 href: entry.path.clone(),
580 });
581 }
582 Shell {
583 asset_urls,
584 icon_set,
585 sprite_url,
586 site_url,
587 base: base.to_string(),
588 brand,
589 nav,
590 crumbs,
591 full_name,
592 initial,
593 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
594 i18n,
595 sidebar,
596 csrf_token,
597 flash,
598 assets: Vec::new(),
599 }
600 }
601
602 pub(crate) fn site_url(&self) -> &str {
604 &self.site_url
605 }
606
607 pub(crate) fn sidebar_anchored(&self) -> bool {
610 self.nav.iter().any(|n| n.active)
611 }
612
613 pub fn icon(&self, name: &str) -> String {
617 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
618 }
619
620 pub(crate) fn asset(&self, key: &str) -> String {
625 match self.asset_urls.get(key) {
626 Some(path) => format!("{}/assets/{path}", self.base),
627 None => {
628 debug_assert!(false, "asset key `{key}` is not registered");
629 format!("{}/assets/{key}", self.base)
630 }
631 }
632 }
633
634 pub fn t(&self, source: &str) -> String {
637 self.i18n.t(&Text::dynamic(source))
638 }
639
640 pub fn tt(&self, text: &Text) -> String {
643 self.i18n.t(text)
644 }
645
646 pub fn i18n(&self) -> &Translator {
649 &self.i18n
650 }
651
652 pub fn locale(&self) -> &str {
654 self.i18n.locale()
655 }
656
657 pub fn base(&self) -> &str {
659 &self.base
660 }
661
662 pub fn csrf_token(&self) -> &str {
666 &self.csrf_token
667 }
668
669 pub fn datetime(&self, utc: &str) -> String {
672 match chrono::DateTime::parse_from_rfc3339(utc) {
673 Ok(at) => at
674 .with_timezone(&self.tz)
675 .format("%Y-%m-%d %H:%M")
676 .to_string(),
677 Err(_) => utc.to_string(),
678 }
679 }
680
681 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
685 self.page_with(PageHead::new(title.clone()), body)
686 }
687
688 pub fn page_with(&self, head: PageHead, body: html::Markup) -> Response {
691 render(PageTemplate {
692 title: self.tt(&head.title),
693 back: head.back.map(|(label, href)| (self.tt(&label), href)),
694 actions: head.actions,
695 body,
696 shell: self.clone(),
697 })
698 }
699
700 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
704 let mut text = Text::dynamic(source);
705 for (name, value) in args {
706 text = text.arg(*name, *value);
707 }
708 self.i18n.t(&text)
709 }
710
711 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
714 let mut text = Text::dynamic(source);
715 for (name, value) in args {
716 text = text.arg(*name, *value);
717 }
718 self.i18n.t(&text)
719 }
720
721 #[cfg(test)]
722 pub(crate) fn test() -> Self {
723 Shell {
724 asset_urls: Arc::new(asset_urls(&builtin_assets())),
725 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
726 sprite_url: Arc::from("/admin/assets/icons.svg"),
727 site_url: "http://localhost".to_string(),
728 base: "/admin".to_string(),
729 brand: "Laterite".to_string(),
730 nav: Vec::new(),
731 full_name: "Test Operator".to_string(),
732 initial: "T".to_string(),
733 tz: Tz::UTC,
734 i18n: Translator::new("en"),
735 sidebar: Vec::new(),
736 crumbs: Vec::new(),
737 csrf_token: "test-csrf-token".to_string(),
738 flash: Vec::new(),
739 assets: Vec::new(),
740 }
741 }
742}
743
744fn settings_context(
751 visible: &[settings::SettingsItem],
752 admin_path: &str,
753 path: &str,
754) -> (bool, Option<String>) {
755 if path == format!("{admin_path}/settings") {
756 return (true, None);
757 }
758 let own = visible
764 .iter()
765 .filter(|i| i.link.is_none())
766 .find(|i| path == format!("{admin_path}{}", i.route()))
767 .map(|i| i.code.clone());
768 if own.is_some() {
769 return (true, own);
770 }
771 let linked = visible
772 .iter()
773 .filter_map(|i| {
774 i.link
775 .as_deref()
776 .map(|link| (format!("{admin_path}{link}"), &i.code))
777 })
778 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
779 .max_by_key(|(link, _)| link.len())
780 .map(|(_, code)| code.clone());
781 (linked.is_some(), linked)
782}
783
784#[allow(clippy::too_many_arguments)]
790fn resolve_nav_context(
791 nav: &nav::NavTree,
792 items: &[settings::SettingsItem],
793 admin_path: &str,
794 perms: &PermissionSet,
795 own_order: Option<&str>,
796 path: &str,
797 tr: &Translator,
798 icons: icons::Icons<'_>,
799) -> (
800 Vec<nav::NavNode>,
801 Vec<settings::CategoryView>,
802 Option<String>,
803) {
804 let mut visible_nav = nav.visible(&|code| perms.allows(code));
805 if let Some(order) = own_order {
806 nav::apply_order(&mut visible_nav, order);
807 }
808 let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
810 .into_iter()
811 .filter(|i| !i.per_operator)
812 .collect();
813 let (in_context, active) = settings_context(&visible, admin_path, path);
814 if in_context {
815 let sidebar = settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons);
816 return (visible_nav, sidebar, Some("settings".to_string()));
817 }
818 let active_top = nav::active_top(&visible_nav, admin_path, path);
819 let sidebar = active_top
820 .map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
821 .unwrap_or_default();
822 let active_key = active_top.map(|node| node.item.key.clone());
823 (visible_nav, sidebar, active_key)
824}
825
826fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
830 preference
831 .and_then(|name| name.parse::<Tz>().ok())
832 .unwrap_or(default_tz)
833}
834
835fn zone_offered(name: &str) -> bool {
837 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
838}
839
840fn base_lang(tag: &str) -> String {
843 tag.split(['-', '_'])
844 .next()
845 .unwrap_or(tag)
846 .to_ascii_lowercase()
847}
848
849fn is_serveable(base: &str, serveable: &[String]) -> bool {
855 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
856}
857
858fn default_locale(configured: &str, serveable: &[String]) -> String {
861 let base = base_lang(configured);
862 if is_serveable(&base, serveable) {
863 base
864 } else {
865 "en".to_string()
866 }
867}
868
869fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
873 let mut out = vec!["en".to_string()];
874 out.extend(catalogs.locales());
875 out
876}
877
878fn locale_name(code: &str) -> String {
883 if code == "en" {
884 "English".to_string()
885 } else {
886 code.to_string()
887 }
888}
889
890fn parse_accept_language(header: &str) -> Vec<String> {
893 let mut items: Vec<(f32, String)> = header
894 .split(',')
895 .filter_map(|part| {
896 let mut bits = part.split(';');
897 let tag = bits.next()?.trim();
898 if tag.is_empty() || tag == "*" {
899 return None;
900 }
901 let q = bits
902 .find_map(|p| p.trim().strip_prefix("q="))
903 .and_then(|q| q.parse::<f32>().ok())
904 .unwrap_or(1.0);
905 Some((q, tag.to_string()))
906 })
907 .collect();
908 items.sort_by(|a, b| b.0.total_cmp(&a.0));
910 items.into_iter().map(|(_, tag)| tag).collect()
911}
912
913fn resolve_locale_chain(
919 preference: Option<&str>,
920 accept_language: Option<&str>,
921 default: &str,
922 serveable: &[String],
923) -> Vec<String> {
924 let mut chain: Vec<String> = Vec::new();
925 let consider = |tag: &str, chain: &mut Vec<String>| {
926 let base = base_lang(tag);
927 if is_serveable(&base, serveable) && !chain.contains(&base) {
928 chain.push(base);
929 }
930 };
931 if let Some(pref) = preference {
932 consider(pref, &mut chain);
933 }
934 if let Some(header) = accept_language {
935 for tag in parse_accept_language(header) {
936 consider(&tag, &mut chain);
937 }
938 }
939 consider(default, &mut chain);
940 consider("en", &mut chain);
943 chain
944}
945
946fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
950 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
951 let serveable = state.catalogs.locales();
952 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
953 Translator::with_chain(chain, state.catalogs.clone())
954}
955
956#[derive(Serialize)]
964#[non_exhaustive]
965pub struct Resource {
966 pub base_path: String,
972 pub nav_label: Text,
974 pub list: list::ListConfig,
975 pub form: Option<form::FormConfig>,
976 pub permission: Option<String>,
983 pub import: Option<ImportConfig>,
986 pub palette: Option<String>,
989 pub relations: Vec<relation::RelationConfig>,
992 pub menu: nav::Placement,
995}
996
997#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1000#[non_exhaustive]
1001pub struct ImportConfig {
1002 #[serde(default)]
1005 pub permission: Option<String>,
1006}
1007
1008impl ImportConfig {
1009 pub fn new() -> Self {
1010 Self::default()
1011 }
1012
1013 pub fn permission(mut self, code: impl Into<String>) -> Self {
1016 self.permission = Some(code.into());
1017 self
1018 }
1019}
1020
1021#[derive(Clone, Serialize)]
1026#[non_exhaustive]
1027pub struct Permission {
1028 pub code: String,
1029 pub label: Text,
1031 pub group: Text,
1032 pub default_roles: Vec<String>,
1035 pub renamed_from: Vec<String>,
1038}
1039
1040pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
1041
1042impl Permission {
1043 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
1046 Self {
1047 code: code.into(),
1048 label: label.into(),
1049 group: group.into(),
1050 default_roles: Vec::new(),
1051 renamed_from: Vec::new(),
1052 }
1053 }
1054
1055 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
1061 where
1062 I: IntoIterator<Item = S>,
1063 S: Into<String>,
1064 {
1065 self.renamed_from = codes.into_iter().map(Into::into).collect();
1066 self
1067 }
1068
1069 pub fn roles<I, S>(mut self, roles: I) -> Self
1072 where
1073 I: IntoIterator<Item = S>,
1074 S: Into<String>,
1075 {
1076 self.default_roles = roles.into_iter().map(Into::into).collect();
1077 self
1078 }
1079}
1080
1081pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
1087 let permissions: Vec<Permission> = builtin_permissions()
1088 .into_iter()
1089 .chain(contributed.iter().cloned())
1090 .collect();
1091 let mut out: Vec<(String, String)> = Vec::new();
1092 for permission in &permissions {
1093 for old in &permission.renamed_from {
1094 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
1095 panic!(
1096 "permissions `{taken}` and `{}` both claim the old code `{old}`",
1097 permission.code
1098 );
1099 }
1100 if permissions.iter().any(|p| &p.code == old) {
1101 panic!(
1102 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
1103 permission.code
1104 );
1105 }
1106 out.push((old.clone(), permission.code.clone()));
1107 }
1108 }
1109 out
1110}
1111
1112pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
1118 let permissions: Vec<Permission> = builtin_permissions()
1121 .into_iter()
1122 .chain(contributed.iter().cloned())
1123 .collect();
1124 let held = |role: &str| -> Vec<String> {
1125 permissions
1126 .iter()
1127 .filter(|p| {
1128 if p.default_roles.is_empty() {
1129 role == ROLE_ADMIN
1130 } else {
1131 p.default_roles.iter().any(|r| r == role)
1132 }
1133 })
1134 .map(|p| p.code.clone())
1135 .collect()
1136 };
1137 vec![
1138 laterite_auth::store::SystemRole {
1139 code: ROLE_ADMIN,
1140 name: "Administrator",
1141 description: "Administers the panel itself: operators, roles and settings.",
1142 permissions: held(ROLE_ADMIN),
1143 },
1144 laterite_auth::store::SystemRole {
1145 code: ROLE_EDITOR,
1146 name: "Editor",
1147 description: "Works with the content the application declares.",
1148 permissions: held(ROLE_EDITOR),
1149 },
1150 ]
1151}
1152
1153impl Resource {
1154 pub fn new(
1159 base_path: impl Into<String>,
1160 nav_label: impl Into<Text>,
1161 list: list::ListConfig,
1162 ) -> Self {
1163 Self {
1164 base_path: base_path.into(),
1165 nav_label: nav_label.into(),
1166 list,
1167 form: None,
1168 permission: None,
1169 import: None,
1170 palette: None,
1171 relations: Vec::new(),
1172 menu: nav::Placement::default(),
1173 }
1174 }
1175
1176 pub fn palette(mut self, source: impl Into<String>) -> Self {
1179 self.palette = Some(source.into());
1180 self
1181 }
1182
1183 pub fn menu(mut self, placement: nav::Placement) -> Self {
1185 self.menu = placement;
1186 self
1187 }
1188
1189 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1191 self.relations.push(relation);
1192 self
1193 }
1194
1195 pub fn import(mut self, import: ImportConfig) -> Self {
1197 self.import = Some(import);
1198 self
1199 }
1200
1201 pub fn form(mut self, form: form::FormConfig) -> Self {
1204 self.form = Some(form);
1205 self
1206 }
1207
1208 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1210 self.permission = Some(permission.into());
1211 self
1212 }
1213}
1214
1215struct SystemRoleGuard;
1221
1222#[laterite_core::strata::async_trait]
1223impl laterite_core::ModelListener for SystemRoleGuard {
1224 async fn before_delete(
1225 &self,
1226 cx: &mut laterite_core::SaveCx<'_>,
1227 rec: &laterite_core::Record,
1228 ) -> Result<(), Text> {
1229 let Some(id) = rec.id() else {
1230 return Ok(());
1231 };
1232 let backend = cx.backend();
1234 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1235 Ok(true) => Err(t!(
1236 "The framework maintains this role; it cannot be deleted."
1237 )),
1238 Err(_) => Err(t!(
1240 "Could not check whether this role is maintained by the framework."
1241 )),
1242 Ok(false) => Ok(()),
1243 }
1244 }
1245}
1246
1247fn builtin_permissions() -> Vec<Permission> {
1250 let mut out = vec![
1253 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1254 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1255 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1256 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1257 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1258 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1259 Permission::new(
1260 "backend.view_request_log",
1261 "View the request log",
1262 "Backend",
1263 ),
1264 ];
1265 out.extend(medialib::permissions());
1266 out
1267}
1268
1269pub fn descriptor_sources() -> Vec<String> {
1275 let mut out = Vec::new();
1276 out.extend(laterite_core::collect_sources(&builtin_resources()));
1277 out.extend(laterite_core::collect_sources(&builtin_settings()));
1278 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1279 out.sort();
1280 out.dedup();
1281 out
1282}
1283
1284pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1299 builtin_modules().iter().map(|m| m.migrations()).collect()
1300}
1301
1302pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1306 vec![
1307 Box::new(laterite_auth::AuthModule),
1308 Box::new(settings::SettingsModule),
1309 Box::new(requestlog::RequestLogModule),
1310 Box::new(plugins::PluginsModule),
1311 Box::new(laterite_media::MediaModule),
1312 Box::new(medialib::MediaLibraryModule),
1313 Box::new(import::ImportModule),
1314 ]
1315}
1316
1317#[derive(Default)]
1341pub struct Contributions {
1342 pub resources: Vec<Resource>,
1344 pub settings: Vec<settings::SettingsItem>,
1346 pub permissions: Vec<Permission>,
1348 pub picker_sources: Vec<picker::PickerSourceReg>,
1350 pub persisters: Vec<persist::PersisterReg>,
1352 pub listeners: Vec<laterite_core::ModelListenerReg>,
1354 pub field_types: Vec<field::FieldTypeReg>,
1358 pub column_types: Vec<list::ColumnTypeReg>,
1359 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1362 pub screens: Vec<routes::ScreenReg>,
1364 pub public_routes: Vec<routes::PublicRouteReg>,
1366 pub nav: Vec<nav::NavItem>,
1369 pub resource_hooks: Vec<ResourceHook>,
1372 pub plugin_defined: Arc<laterite_core::Registry>,
1377}
1378
1379pub fn router(
1380 auth: AuthService,
1381 db: Db,
1382 contributions: Contributions,
1383 config: AdminConfig,
1384 catalogs: Arc<CatalogStore>,
1385) -> Router {
1386 let Contributions {
1387 resources: app_resources,
1388 settings: app_settings,
1389 permissions: app_permissions,
1390 picker_sources: app_picker_sources,
1391 persisters: app_persisters,
1392 listeners: app_listeners,
1393 field_types: app_field_types,
1394 column_types: app_column_types,
1395 icons: app_icons,
1396 screens: app_screens,
1397 public_routes: app_public,
1398 nav: app_nav,
1399 resource_hooks,
1400 plugin_defined,
1401 } = contributions;
1402 let mut app_screens = app_screens;
1405 app_screens.push(medialib::screen());
1406 let mut app_public = app_public;
1407 app_public.push(medialib::serve_route());
1408 let admin_path = normalize_path(&config.path);
1409
1410 let mut resources = builtin_resources();
1411 let mut app_resources = app_resources;
1412 for hook in resource_hooks {
1415 let found = resources
1416 .iter_mut()
1417 .chain(app_resources.iter_mut())
1418 .find(|r| r.base_path == hook.target);
1419 match found {
1420 Some(resource) => {
1421 let changed = hook.apply(std::mem::replace(
1422 resource,
1423 Resource::new("", "", list::ListConfig::default()),
1424 ));
1425 *resource = changed;
1426 }
1427 None => panic!(
1428 "a resource hook targets `{}`, which no module declared",
1429 hook.target
1430 ),
1431 }
1432 }
1433 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1437 prefix_resource(&admin_path, resource);
1438 }
1439 for resource in app_resources.iter_mut() {
1443 if let Some(import) = &resource.import {
1444 let permission = import
1445 .permission
1446 .clone()
1447 .or_else(|| resource.permission.clone())
1448 .unwrap_or_else(|| {
1449 panic!(
1450 "admin resource `{}` declares `import` but no permission; importing \
1451 writes records, so it must be gated",
1452 resource.base_path
1453 )
1454 });
1455 resource.list.import = Some(routes::feature_link(
1456 &resource.base_path,
1457 "import",
1458 &permission,
1459 ));
1460 }
1461 }
1462
1463 let mut settings = builtin_settings();
1464 settings.extend(app_settings);
1465 let mut app_listeners = app_listeners;
1468 app_listeners.insert(
1469 0,
1470 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1471 );
1472
1473 let mut permissions = builtin_permissions();
1474 permissions.extend(app_permissions);
1475 let permission_codes: std::collections::HashSet<String> =
1477 permissions.iter().map(|p| p.code.clone()).collect();
1478
1479 let mut nav_items = vec![
1485 nav::NavItem::new("dashboard", "Dashboard", "")
1486 .icon("layout-dashboard")
1487 .order(i32::MIN),
1488 nav::NavItem::new("settings", "Settings", "/settings")
1489 .icon("settings")
1490 .order(i32::MAX),
1491 ];
1492 nav_items.extend(app_nav);
1493 for resource in &app_resources {
1494 if resource.menu.hidden {
1495 continue;
1496 }
1497 let unprefixed = resource
1498 .base_path
1499 .strip_prefix(admin_path.as_str())
1500 .unwrap_or(&resource.base_path)
1501 .to_string();
1502 let mut item = nav::NavItem::new(
1503 format!("resource:{unprefixed}"),
1504 resource.nav_label.clone(),
1505 unprefixed,
1506 )
1507 .order(resource.menu.order);
1508 item.icon = resource.menu.icon.clone();
1509 item.permission = resource.permission.clone();
1510 item.under = resource.menu.under.clone();
1511 item.after = resource.menu.after.clone();
1512 item.before = resource.menu.before.clone();
1513 nav_items.push(item);
1514 }
1515 for reg in &app_screens {
1516 let Some(label) = ®.nav_label else {
1517 continue;
1518 };
1519 if reg.menu.hidden {
1520 continue;
1521 }
1522 let mut item = nav::NavItem::new(
1523 format!("screen:{}", reg.base_path),
1524 label.clone(),
1525 reg.base_path.clone(),
1526 )
1527 .permission(reg.permission.clone())
1528 .order(reg.menu.order);
1529 item.icon = reg.menu.icon.clone();
1530 item.under = reg.menu.under.clone();
1531 item.after = reg.menu.after.clone();
1532 item.before = reg.menu.before.clone();
1533 nav_items.push(item);
1534 }
1535 let nav =
1536 nav::NavTree::build(nav_items, &admin_path).unwrap_or_else(|e| panic!("admin menu: {e}"));
1537 let palette_sources: Vec<palette::RecordSource> = app_resources
1540 .iter()
1541 .filter_map(|r| {
1542 r.palette.as_ref().map(|source| palette::RecordSource {
1543 label: r.nav_label.clone(),
1544 source: source.clone(),
1545 base_path: r.base_path.clone(),
1546 permission: r.permission.clone(),
1547 })
1548 })
1549 .collect();
1550 resources.extend(app_resources);
1551
1552 let app_name = if config.app_name.trim().is_empty() {
1553 "Laterite".to_string()
1554 } else {
1555 config.app_name.clone()
1556 };
1557
1558 let mut pickers = picker::PickerRegistry::new();
1561 for reg in app_picker_sources {
1562 let name = reg.name.clone();
1563 if !field::is_name(&name, true) {
1564 panic!("invalid picker source name `{name}`");
1565 }
1566 if pickers.insert(name.clone(), reg).is_some() {
1567 panic!("duplicate picker source `{name}`");
1568 }
1569 }
1570
1571 for source in &palette_sources {
1575 if !pickers.contains_key(&source.source) {
1576 panic!(
1577 "admin resource `{}` names palette source `{}`, which no module registered",
1578 source.base_path, source.source
1579 );
1580 }
1581 }
1582 let pickers = Arc::new(pickers);
1583 let mut field_types = field::builtin_registry();
1584 field_types.insert(
1585 "reference".to_string(),
1586 Arc::new(field::RefPickerField::new(pickers.clone())),
1587 );
1588
1589 let mut persisters = persist::PersisterRegistry::new();
1592 for reg in app_persisters {
1593 let name = reg.name.clone();
1594 if !field::is_name(&name, true) {
1595 panic!("invalid persister name `{name}`");
1596 }
1597 if persisters.insert(name.clone(), reg.persister).is_some() {
1598 panic!("duplicate persister `{name}`");
1599 }
1600 }
1601
1602 for reg in app_field_types {
1608 let name = reg.name().to_string();
1609 if !field::is_name(&name, true) {
1610 panic!("invalid field type name `{name}`");
1611 }
1612 if field_types.insert(name.clone(), reg.field_type).is_some() {
1613 panic!("duplicate field type `{name}`");
1614 }
1615 }
1616
1617 let mut icon_set = laterite_core::icons::IconSet::new();
1621 for (owner, reg) in app_icons {
1622 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1623 panic!("module `{owner}` contributed an invalid icon: {e}");
1624 }
1625 }
1626
1627 let mut assets = builtin_assets();
1630 assets.insert(
1631 ICON_SPRITE,
1632 AdminAsset {
1633 mime: "image/svg+xml",
1634 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1635 },
1636 );
1637
1638 for item in &settings {
1642 icons::require(
1643 &icon_set,
1644 &format!("settings item `{}`", item.code),
1645 item.icon.as_deref(),
1646 );
1647 }
1648 for node in nav
1649 .top
1650 .iter()
1651 .flat_map(|n| std::iter::once(n).chain(n.children.iter()))
1652 {
1653 icons::require(
1654 &icon_set,
1655 &format!("menu entry `{}`", node.item.key),
1656 node.item.icon.as_deref(),
1657 );
1658 }
1659
1660 let mut column_types = list::builtin_column_registry();
1664 for reg in app_column_types {
1665 let name = reg.name().to_string();
1666 if !field::is_name(&name, true) {
1667 panic!("invalid column type name `{name}`");
1668 }
1669 if column_types.insert(name.clone(), reg.column_type).is_some() {
1670 panic!("duplicate column type `{name}`");
1671 }
1672 }
1673
1674 let settings = Arc::new(settings);
1675 let asset_urls_arc = Arc::new(asset_urls(&assets));
1677 let sprite_url: Arc<str> = Arc::from(
1678 asset_urls_arc
1679 .get(ICON_SPRITE)
1680 .map(|p| format!("{admin_path}/assets/{p}"))
1681 .unwrap_or_default()
1682 .as_str(),
1683 );
1684 let icons_arc = Arc::new(icon_set);
1685 let state = AdminState {
1686 auth,
1687 db,
1688 nav: Arc::new(nav),
1689 settings: settings.clone(),
1690 permissions: Arc::new(permissions),
1691 admin_path: Arc::from(admin_path.as_str()),
1692 secure_cookie: config.secure_cookie,
1693 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1694 icons: icons_arc.clone(),
1695 sprite_url: sprite_url.clone(),
1696 origin: Arc::from(config.origin.trim_end_matches('/')),
1697 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1698 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1699 catalogs,
1700 app_name,
1701 brand_cache: Arc::new(RwLock::new(None)),
1702 field_types: Arc::new(field_types),
1703 column_types: Arc::new(column_types),
1704 plugin_defined,
1705 storage_dir: Arc::from(config.storage_dir.trim_end_matches('/')),
1706 signing_key: Arc::new(config.secret.clone()),
1707 cookie_path: if app_public.iter().any(|r| r.is_site()) {
1708 Arc::from("/")
1709 } else {
1710 Arc::from(admin_path.as_str())
1711 },
1712 assets: Arc::new(assets),
1713 asset_urls: asset_urls_arc.clone(),
1714 pickers,
1715 palette: Arc::new(palette_sources),
1716 overrides: Arc::new(field::NoOverrides),
1717 };
1718
1719 let mut protected = Router::new().route(&admin_path, get(dashboard));
1720 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
1724 for resource in &resources {
1725 let (mounted, prepared) = mount_resource(
1726 resource,
1727 &state.field_types,
1728 &state.column_types,
1729 &permission_codes,
1730 &persisters,
1731 &app_listeners,
1732 );
1733 protected = protected.merge(mounted);
1734 if let Some(prepared) = prepared {
1735 let key = resource
1736 .base_path
1737 .strip_prefix(admin_path.as_str())
1738 .unwrap_or(&resource.base_path);
1739 forms.insert(key.to_string(), prepared);
1740 }
1741 }
1742 let forms: form::Forms = Arc::new(forms);
1743 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
1747 .plugin_defined
1748 .items::<laterite_media::DiskReg>()
1749 .into_iter()
1750 .find(|d| d.name == import::DISK)
1751 .map(|d| d.driver.clone())
1752 .unwrap_or_else(|| {
1753 Arc::new(laterite_media::LocalDisk::new(format!(
1754 "{}/{}",
1755 config.storage_dir.trim_end_matches('/'),
1756 import::DISK
1757 )))
1758 });
1759 for resource in &resources {
1760 let Some(link) = &resource.list.import else {
1761 continue;
1762 };
1763 let key = resource
1764 .base_path
1765 .strip_prefix(admin_path.as_str())
1766 .unwrap_or(&resource.base_path)
1767 .to_string();
1768 let prepared = forms.get(&key).unwrap_or_else(|| {
1769 panic!(
1770 "admin resource `{}` declares `import` but has no form to import through",
1771 resource.base_path
1772 )
1773 });
1774 let feature = import::ImportFeature {
1775 disk: private_disk.clone(),
1776 };
1777 let base = link.href.clone();
1778 let ctx = routes::RouteCtx::new(
1779 state.db.clone(),
1780 state.auth.clone(),
1781 state.field_types.clone(),
1782 state.overrides.clone(),
1783 state.signing_key.clone(),
1784 &state.storage_dir,
1785 &base,
1786 &admin_path,
1787 &state.origin,
1788 state.plugin_defined.clone(),
1789 state.auth.events().clone(),
1790 forms.clone(),
1791 );
1792 let info = routes::ResourceInfo::new(
1793 key,
1794 resource.base_path.clone(),
1795 resource.nav_label.clone(),
1796 link.permission.clone(),
1797 form::FormHandle::new(state.db.clone(), prepared.clone()),
1798 );
1799 protected = protected.merge(guard_with_permission(
1800 Router::new()
1801 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
1802 &link.permission,
1803 ));
1804 }
1805 for reg in &app_screens {
1809 let base = format!("{admin_path}{}", reg.base_path);
1810 let ctx = routes::RouteCtx::new(
1811 state.db.clone(),
1812 state.auth.clone(),
1813 state.field_types.clone(),
1814 state.overrides.clone(),
1815 state.signing_key.clone(),
1816 &state.storage_dir,
1817 &base,
1818 &admin_path,
1819 &state.origin,
1820 state.plugin_defined.clone(),
1821 state.auth.events().clone(),
1822 forms.clone(),
1823 );
1824 protected = protected.merge(guard_with_permission(
1825 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1826 ®.permission,
1827 ));
1828 }
1829
1830 protected = protected.merge(guard_with_permission(
1833 Router::new()
1834 .route(
1835 &format!("{admin_path}/roles/new"),
1836 get(roles::new_form).post(roles::create),
1837 )
1838 .route(
1839 &format!("{admin_path}/roles/{{id}}/edit"),
1840 get(roles::edit_form).post(roles::update),
1841 ),
1842 "backend.manage_roles",
1843 ));
1844 protected = protected.merge(guard_with_permission(
1847 Router::new()
1848 .route(
1849 &format!("{admin_path}/users/new"),
1850 get(users::new_form).post(users::create),
1851 )
1852 .route(
1853 &format!("{admin_path}/users/{{id}}/edit"),
1854 get(users::edit_form).post(users::update),
1855 )
1856 .route(
1857 &format!("{admin_path}/users/{{id}}/active"),
1858 post(users::set_active),
1859 )
1860 .route(
1861 &format!("{admin_path}/users/{{id}}/password"),
1862 post(users::reset_password),
1863 )
1864 .route(
1865 &format!("{admin_path}/users/{{id}}/unlock"),
1866 post(users::unlock),
1867 ),
1868 "backend.manage_users",
1869 ));
1870 protected = protected.merge(guard_with_permission(
1873 Router::new()
1874 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1875 .route(
1876 &format!("{admin_path}/plugins/toggle"),
1877 post(plugins::toggle),
1878 ),
1879 plugins::MANAGE_PERMISSION,
1880 ));
1881 protected = protected
1882 .route(&format!("{admin_path}/settings"), get(settings_index))
1883 .route(
1884 &format!("{admin_path}/preferences"),
1885 get(preferences_form).post(preferences_update),
1886 )
1887 .route(
1888 &format!("{admin_path}/preferences/password"),
1889 post(password_update),
1890 )
1891 .route(
1892 &format!("{admin_path}/preferences/menu"),
1893 post(menu_order_update),
1894 )
1895 .route(&format!("{admin_path}/palette"), get(palette::palette))
1896 .route(
1897 &format!("{admin_path}/preferences/sessions/revoke"),
1898 post(session_revoke),
1899 )
1900 .route(
1901 &format!("{admin_path}/preferences/sessions/revoke-others"),
1902 post(session_revoke_others),
1903 )
1904 .route(
1907 &format!("{admin_path}/pickers/{{source}}/search"),
1908 any(picker::search),
1909 )
1910 .route(
1911 &format!("{admin_path}/pickers/{{source}}/resolve"),
1912 any(picker::resolve),
1913 )
1914 .route(&format!("{admin_path}/logout"), post(logout));
1915
1916 for item in settings.iter().filter(|i| i.link.is_none()) {
1920 let path = format!("{admin_path}{}", item.route());
1921 let code = item.code.clone();
1922 let edit_code = code.clone();
1923 protected = protected.route(
1924 &path,
1925 get(
1926 move |State(state): State<AdminState>,
1927 Extension(shell): Extension<Shell>,
1928 Extension(user): Extension<AuthenticatedUser>| {
1929 let code = edit_code.clone();
1930 async move { settings_edit(state, shell, user, code).await }
1931 },
1932 )
1933 .post(
1934 move |State(state): State<AdminState>,
1935 Extension(shell): Extension<Shell>,
1936 Extension(user): Extension<AuthenticatedUser>,
1937 Extension(session): Extension<session::SessionHandle>,
1938 Form(data): Form<HashMap<String, String>>| {
1939 let code = code.clone();
1940 async move { settings_update(state, shell, user, session, code, data).await }
1941 },
1942 ),
1943 );
1944 }
1945
1946 let app = protected
1947 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1948 .route(
1952 &format!("{admin_path}/login"),
1953 get(login_form).post(login_submit),
1954 )
1955 .route(
1956 &format!("{admin_path}/setup"),
1957 get(setup_form).post(setup_submit),
1958 )
1959 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1960 .merge(mount_public(
1964 &app_public,
1965 &state.db,
1966 &state.auth,
1967 &state.field_types,
1968 &state.overrides,
1969 &state.signing_key,
1970 &state.storage_dir,
1971 &admin_path,
1972 &state.origin,
1973 &state.plugin_defined,
1974 state.auth.events(),
1975 &forms,
1976 ));
1977 let app = match app_public.iter().find(|r| r.is_site()) {
1980 Some(site) => {
1981 let ctx = routes::RouteCtx::new(
1982 state.db.clone(),
1983 state.auth.clone(),
1984 state.field_types.clone(),
1985 state.overrides.clone(),
1986 state.signing_key.clone(),
1987 &state.storage_dir,
1988 "/",
1989 &admin_path,
1990 &state.origin,
1991 state.plugin_defined.clone(),
1992 state.auth.events().clone(),
1993 forms.clone(),
1994 );
1995 app.fallback_service(site.route.mount(&ctx))
1996 }
1997 None => app.fallback(not_found_fallback),
1998 };
1999 app
2000 .layer(middleware::from_fn_with_state(
2003 state.clone(),
2004 enforce_origin,
2005 ))
2006 .layer(middleware::from_fn_with_state(
2009 state.clone(),
2010 capture_client,
2011 ))
2012 .layer(
2017 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2018 header::CACHE_CONTROL,
2019 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
2020 ),
2021 )
2022 .layer(
2026 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2027 header::VARY,
2028 axum::http::HeaderValue::from_static("HX-Request"),
2029 ),
2030 )
2031 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
2032 handle_panic,
2033 ))
2034 .with_state(state)
2035}
2036
2037async fn not_found_fallback(
2040 State(state): State<AdminState>,
2041 method: axum::http::Method,
2042 uri: axum::http::Uri,
2043) -> AdminError {
2044 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
2045 AdminError::NotFound
2046}
2047
2048async fn not_found_plain() -> AdminError {
2050 AdminError::NotFound
2051}
2052
2053fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
2055 let cause = err
2056 .downcast_ref::<&str>()
2057 .map(|s| s.to_string())
2058 .or_else(|| err.downcast_ref::<String>().cloned())
2059 .unwrap_or_else(|| "unknown panic".to_string());
2060 tracing::error!(panic = cause, "admin handler panicked");
2061 error::masked_500()
2062}
2063
2064pub fn normalize_path(path: &str) -> String {
2069 let trimmed = path.trim().trim_matches('/');
2070 if trimmed.is_empty() {
2071 "/admin".to_string()
2072 } else {
2073 format!("/{trimmed}")
2074 }
2075}
2076
2077#[allow(clippy::too_many_arguments)]
2081fn mount_public(
2082 regs: &[routes::PublicRouteReg],
2083 db: &Db,
2084 auth: &AuthService,
2085 field_types: &Arc<field::FieldRegistry>,
2086 overrides: &Arc<dyn field::OverrideResolver>,
2087 signing_key: &Arc<Vec<u8>>,
2088 storage_dir: &str,
2089 admin_path: &str,
2090 origin: &str,
2091 plugin_defined: &Arc<laterite_core::Registry>,
2092 events: &laterite_core::Events,
2093 forms: &form::Forms,
2094) -> Router<AdminState> {
2095 let mut router = Router::new();
2096 for reg in regs.iter().filter(|r| !r.is_site()) {
2098 let ctx = routes::RouteCtx::new(
2099 db.clone(),
2100 auth.clone(),
2101 field_types.clone(),
2102 overrides.clone(),
2103 signing_key.clone(),
2104 storage_dir,
2105 ®.path,
2106 admin_path,
2107 origin,
2108 plugin_defined.clone(),
2109 events.clone(),
2110 forms.clone(),
2111 );
2112 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
2117 }
2118 router
2119}
2120
2121pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
2124 let old = resource.base_path.clone();
2125 resource.base_path = base.to_string();
2126 if let Some(edit_base) = &mut resource.list.edit_base {
2127 if edit_base == &old {
2128 *edit_base = base.to_string();
2129 }
2130 }
2131 if let Some(form) = &mut resource.form {
2132 if form.base_path == old {
2133 form.base_path = base.to_string();
2134 }
2135 }
2136}
2137
2138fn prefix_resource(admin_path: &str, resource: &mut Resource) {
2139 resource.base_path = format!("{admin_path}{}", resource.base_path);
2140 if let Some(edit_base) = &mut resource.list.edit_base {
2141 *edit_base = format!("{admin_path}{edit_base}");
2142 }
2143 if let Some(form) = &mut resource.form {
2144 form.base_path = format!("{admin_path}{}", form.base_path);
2145 }
2146}
2147
2148pub(crate) struct AdminAsset {
2150 pub mime: &'static str,
2151 pub bytes: std::borrow::Cow<'static, [u8]>,
2155}
2156
2157pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2160
2161pub(crate) type AssetUrls = HashMap<&'static str, String>;
2165
2166pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2168 registry
2169 .iter()
2170 .map(|(&key, asset)| {
2171 (
2172 key,
2173 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2174 )
2175 })
2176 .collect()
2177}
2178
2179pub(crate) fn page_assets(
2186 keys: &[&str],
2187 base: &str,
2188 registry: &AssetRegistry,
2189 urls: &AssetUrls,
2190) -> Vec<PageAsset> {
2191 let mut seen = std::collections::HashSet::new();
2192 let mut out = Vec::new();
2193 for &key in keys {
2194 if !seen.insert(key) {
2195 continue;
2196 }
2197 match (registry.get(key), urls.get(key)) {
2198 (Some(asset), Some(path)) => out.push(PageAsset {
2199 url: format!("{base}/assets/{path}"),
2200 css: asset.mime.contains("css"),
2201 }),
2202 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2203 }
2204 }
2205 out
2206}
2207
2208pub(crate) fn builtin_assets() -> AssetRegistry {
2217 let font = |bytes: &'static [u8]| AdminAsset {
2218 mime: "font/woff2",
2219 bytes: std::borrow::Cow::Borrowed(bytes),
2220 };
2221 HashMap::from([
2222 (
2223 "laterite.css",
2224 AdminAsset {
2225 mime: "text/css; charset=utf-8",
2226 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2227 },
2228 ),
2229 (
2230 "laterite.js",
2231 AdminAsset {
2232 mime: "text/javascript; charset=utf-8",
2233 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2234 },
2235 ),
2236 (
2237 "vendor/htmx.min.js",
2238 AdminAsset {
2239 mime: "text/javascript; charset=utf-8",
2240 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2241 },
2242 ),
2243 (
2244 "fields/ref-picker.js",
2245 AdminAsset {
2246 mime: "text/javascript; charset=utf-8",
2247 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
2248 },
2249 ),
2250 (
2251 "fields/ref-picker.css",
2252 AdminAsset {
2253 mime: "text/css; charset=utf-8",
2254 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2255 "../assets/fields/ref-picker.css"
2256 )),
2257 },
2258 ),
2259 (
2260 "mark.svg",
2261 AdminAsset {
2262 mime: "image/svg+xml",
2263 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
2264 },
2265 ),
2266 (
2267 "mark.png",
2268 AdminAsset {
2269 mime: "image/png",
2270 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
2271 },
2272 ),
2273 (
2274 "fonts/space-grotesk-500.woff2",
2275 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
2276 ),
2277 (
2278 "fonts/space-grotesk-600.woff2",
2279 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
2280 ),
2281 (
2282 "fonts/space-grotesk-700.woff2",
2283 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
2284 ),
2285 (
2286 "fonts/ibm-plex-sans-400.woff2",
2287 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
2288 ),
2289 (
2290 "fonts/ibm-plex-sans-600.woff2",
2291 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
2292 ),
2293 (
2294 "fonts/ibm-plex-mono-400.woff2",
2295 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
2296 ),
2297 (
2298 "fonts/ibm-plex-mono-600.woff2",
2299 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
2300 ),
2301 ])
2302}
2303
2304async fn serve_asset(
2306 State(state): State<AdminState>,
2307 headers: HeaderMap,
2308 Path(path): Path<String>,
2309) -> Response {
2310 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
2314 let Some(asset) = state.assets.get(key.as_str()) else {
2315 return not_found();
2316 };
2317 let policy = if fingerprinted {
2318 http_cache::IMMUTABLE
2319 } else {
2320 http_cache::REVALIDATE
2321 };
2322 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
2323}
2324
2325fn mount_resource(
2330 resource: &Resource,
2331 field_types: &field::FieldRegistry,
2332 column_types: &list::ColumnRegistry,
2333 permission_codes: &std::collections::HashSet<String>,
2334 persisters: &persist::PersisterRegistry,
2335 listeners: &[laterite_core::ModelListenerReg],
2336) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2337 list::check(&resource.list, column_types, permission_codes)
2341 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2342 if resource.form.is_some() && resource.permission.is_none() {
2347 panic!(
2348 "admin resource `{}` has a create/edit form but no permission; a write \
2349 resource must declare a permission",
2350 resource.base_path
2351 );
2352 }
2353 let base = resource.base_path.clone();
2354 let list_path = base.clone();
2355 let mut list_cfg = resource.list.clone();
2356 if resource.list.columns.iter().any(|c| c.editable) {
2359 let Some(form_cfg) = resource.form.clone() else {
2360 panic!(
2361 "admin resource `{}` has an editable column but no form to edit through",
2362 resource.base_path
2363 );
2364 };
2365 for column in resource.list.columns.iter().filter(|c| c.editable) {
2366 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2367 panic!(
2368 "admin resource `{}`: column `{}` is editable but the form has no such field",
2369 resource.base_path, column.field
2370 );
2371 }
2372 }
2373 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2374 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2375 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2376 }
2377 let cell_cfg = list_cfg.clone();
2378 let mut router = Router::new().route(
2379 &base,
2380 get(
2381 move |State(state): State<AdminState>,
2382 Extension(shell): Extension<Shell>,
2383 Query(params): Query<list::ListParams>,
2384 Query(raw): Query<std::collections::HashMap<String, String>>,
2385 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2386 Extension(user): Extension<AuthenticatedUser>,
2387 Extension(session): Extension<session::SessionHandle>,
2388 headers: axum::http::HeaderMap| {
2389 let cfg = list_cfg.clone();
2390 let path = list_path.clone();
2391 async move {
2392 list::handle_remembering(
2393 &state,
2394 &cfg,
2395 &path,
2396 params,
2397 &raw,
2398 &user,
2399 shell,
2400 &headers,
2401 query_string.as_deref(),
2402 Some(&session),
2403 )
2404 .await
2405 }
2406 },
2407 ),
2408 );
2409
2410 if cell_cfg.editor.is_some() {
2411 let (cfg, list_path) = (cell_cfg, base.clone());
2412 router = router.route(
2413 &format!("{base}/{{id}}/cell"),
2414 post(
2415 move |State(state): State<AdminState>,
2416 Extension(shell): Extension<Shell>,
2417 Extension(user): Extension<AuthenticatedUser>,
2418 Extension(session): Extension<session::SessionHandle>,
2419 Path(id): Path<String>,
2420 headers: axum::http::HeaderMap,
2421 Form(data): Form<HashMap<String, String>>| {
2422 let (cfg, path) = (cfg.clone(), list_path.clone());
2423 async move {
2424 list::cell_handler(
2425 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2426 )
2427 .await
2428 }
2429 },
2430 ),
2431 );
2432 }
2433
2434 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2437 if resource.list.exportable {
2438 router = router.route(
2439 &format!("{base}/export"),
2440 get(
2441 move |state: State<AdminState>,
2442 Query(raw): Query<std::collections::HashMap<String, String>>,
2443 Extension(user): Extension<AuthenticatedUser>,
2444 Extension(shell): Extension<Shell>,
2445 Extension(session): Extension<session::SessionHandle>| {
2446 let cfg = export_cfg.clone();
2447 let path = export_path.clone();
2448 async move {
2449 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2450 }
2451 },
2452 ),
2453 );
2454 }
2455
2456 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2457 router = router.route(
2458 &format!("{base}/columns"),
2459 post(
2460 move |State(state): State<AdminState>,
2461 Extension(user): Extension<AuthenticatedUser>,
2462 Extension(session): Extension<session::SessionHandle>,
2463 headers: axum::http::HeaderMap,
2464 Form(pairs): Form<Vec<(String, String)>>| {
2465 let cfg = columns_cfg.clone();
2466 let path = columns_path.clone();
2467 async move {
2468 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2469 }
2470 },
2471 ),
2472 );
2473
2474 if resource.list.deletable {
2475 let persister: Arc<dyn persist::Persister> =
2479 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
2480 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
2481 panic!(
2482 "admin resource `{}`: unregistered persister `{key}`",
2483 resource.base_path
2484 )
2485 }),
2486 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
2487 };
2488 let ctx = Arc::new(bulk::BulkContext {
2489 base_path: resource.base_path.clone(),
2490 entity: resource.list.entity.clone(),
2491 persister,
2492 listeners: listeners
2493 .iter()
2494 .filter(|reg| reg.matches(&resource.list.entity))
2495 .map(|reg| reg.listener.clone())
2496 .collect(),
2497 });
2498 router =
2499 router.route(
2500 &format!("{base}/delete"),
2501 post(
2502 move |state: State<AdminState>,
2503 user: Extension<AuthenticatedUser>,
2504 session: Extension<session::SessionHandle>,
2505 headers: axum::http::HeaderMap,
2506 form: Form<Vec<(String, String)>>| {
2507 let ctx = ctx.clone();
2508 async move {
2509 bulk::delete_handler(state, user, session, ctx, headers, form).await
2510 }
2511 },
2512 ),
2513 );
2514 }
2515
2516 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
2519 let tree = resource.list.tree.clone().unwrap_or_default();
2520 let mut move_cfg = form::FormConfig::new(
2521 resource.list.entity.clone(),
2522 laterite_core::t!("Move to"),
2523 base.clone(),
2524 resource.list.id_field.clone(),
2525 vec![form::FormField::reference(
2526 &tree.parent_key,
2527 laterite_core::t!("New parent"),
2528 &source,
2529 )
2530 .help(laterite_core::t!("Leave empty for the top level."))],
2531 );
2532 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
2533 move_cfg = move_cfg.persist(persist);
2534 }
2535 move_cfg.done_path = Some(base.clone());
2536 let move_form = Arc::new(
2537 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
2538 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
2539 );
2540 let (cfg, list_path) = (resource.list.clone(), base.clone());
2541 let (form_get, form_post) = (move_form.clone(), move_form);
2542 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
2543 router = router.route(
2544 &format!("{base}/{{id}}/move"),
2545 get(
2546 move |State(state): State<AdminState>,
2547 Extension(shell): Extension<Shell>,
2548 Path(id): Path<String>| {
2549 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
2550 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
2551 },
2552 )
2553 .post(
2554 move |State(state): State<AdminState>,
2555 Extension(shell): Extension<Shell>,
2556 Extension(user): Extension<AuthenticatedUser>,
2557 Extension(session): Extension<session::SessionHandle>,
2558 Path(id): Path<String>,
2559 headers: axum::http::HeaderMap,
2560 Form(data): Form<HashMap<String, String>>| {
2561 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
2562 async move {
2563 list::move_record(
2564 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
2565 )
2566 .await
2567 }
2568 },
2569 ),
2570 );
2571 }
2572
2573 if resource.list.reorder.is_some() {
2574 let (cfg, list_path) = (resource.list.clone(), base.clone());
2575 router = router.route(
2576 &format!("{base}/reorder"),
2577 post(
2578 move |State(state): State<AdminState>,
2579 Extension(shell): Extension<Shell>,
2580 Extension(user): Extension<AuthenticatedUser>,
2581 Extension(session): Extension<session::SessionHandle>,
2582 headers: axum::http::HeaderMap,
2583 Form(pairs): Form<Vec<(String, String)>>| {
2584 let cfg = cfg.clone();
2585 let path = list_path.clone();
2586 async move {
2587 list::reorder_handler(
2588 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
2589 &headers, &pairs,
2590 )
2591 .await
2592 }
2593 },
2594 ),
2595 );
2596 }
2597
2598 if !resource.relations.is_empty() && resource.form.is_none() {
2601 panic!(
2602 "admin resource `{}` declares relations but has no form to show them on",
2603 resource.base_path
2604 );
2605 }
2606 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
2607 for rel in &resource.relations {
2608 let prepared = relation::PreparedRelation::prepare(
2609 resource,
2610 rel.clone(),
2611 field_types,
2612 column_types,
2613 permission_codes,
2614 persisters,
2615 listeners,
2616 )
2617 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2618 relations.push(Arc::new(prepared));
2619 }
2620 let relations = Arc::new(relations);
2621
2622 let mut prepared_form = None;
2623 if let Some(form_cfg) = resource.form.clone() {
2624 let mut prepared =
2627 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2628 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2629 prepared.stay = !relations.is_empty();
2631 let prepared = Arc::new(prepared);
2632 prepared_form = Some(prepared.clone());
2633 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
2634 let relations_for_new = relations.clone();
2635 router = router.route(
2636 &format!("{base}/new"),
2637 get(
2638 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
2639 let pf = new_pf.clone();
2640 let relations = relations_for_new.clone();
2641 async move {
2642 let pending = relations
2643 .iter()
2644 .map(|r| shell.tt(&r.config.title))
2645 .collect();
2646 form::new_form(&state, &pf, shell, pending)
2647 }
2648 },
2649 )
2650 .post(
2651 move |State(state): State<AdminState>,
2652 Extension(shell): Extension<Shell>,
2653 Extension(user): Extension<AuthenticatedUser>,
2654 Extension(session): Extension<session::SessionHandle>,
2655 headers: axum::http::HeaderMap,
2656 Form(data): Form<HashMap<String, String>>| {
2657 let pf = create_pf.clone();
2658 async move {
2659 form::create(&state, &pf, data, shell, &user, &session, &headers).await
2660 }
2661 },
2662 ),
2663 );
2664
2665 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
2666 let relations_for_edit = relations.clone();
2667 router = router.route(
2668 &format!("{base}/{{id}}/edit"),
2669 get(
2670 move |State(state): State<AdminState>,
2671 Extension(shell): Extension<Shell>,
2672 Extension(user): Extension<AuthenticatedUser>,
2673 Path(id): Path<String>| {
2674 let pf = edit_pf.clone();
2675 let relations = relations_for_edit.clone();
2676 async move {
2677 let mut sections = Vec::new();
2678 for rel in relations.iter() {
2679 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
2680 sections.push(section);
2681 }
2682 }
2683 form::edit_form(&state, &pf, id, shell, sections).await
2684 }
2685 },
2686 )
2687 .post(
2688 move |State(state): State<AdminState>,
2689 Extension(shell): Extension<Shell>,
2690 Extension(user): Extension<AuthenticatedUser>,
2691 Extension(session): Extension<session::SessionHandle>,
2692 Path(id): Path<String>,
2693 headers: axum::http::HeaderMap,
2694 Form(data): Form<HashMap<String, String>>| {
2695 let pf = update_pf.clone();
2696 async move {
2697 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
2698 }
2699 },
2700 ),
2701 );
2702 }
2703
2704 for rel in relations.iter() {
2705 let name = rel.config.name.clone();
2706 let mut routes: Router<AdminState> = Router::new();
2707 let r = rel.clone();
2708 routes =
2709 routes.route(
2710 &format!("{base}/{{id}}/{name}"),
2711 get(
2712 move |state: State<AdminState>,
2713 shell: Extension<Shell>,
2714 user: Extension<AuthenticatedUser>,
2715 id: Path<String>,
2716 params: Query<list::ListParams>,
2717 raw: Query<HashMap<String, String>>,
2718 headers: axum::http::HeaderMap| {
2719 let r = r.clone();
2720 async move {
2721 relation::index(state, shell, user, id, params, raw, headers, r).await
2722 }
2723 },
2724 ),
2725 );
2726 let (r_new, r_create) = (rel.clone(), rel.clone());
2727 routes = routes.route(
2728 &format!("{base}/{{id}}/{name}/new"),
2729 get(
2730 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
2731 let r = r_new.clone();
2732 async move { relation::new_form(state, shell, id, r).await }
2733 },
2734 )
2735 .post(
2736 move |state: State<AdminState>,
2737 shell: Extension<Shell>,
2738 user: Extension<AuthenticatedUser>,
2739 session: Extension<session::SessionHandle>,
2740 id: Path<String>,
2741 headers: axum::http::HeaderMap,
2742 data: Form<HashMap<String, String>>| {
2743 let r = r_create.clone();
2744 async move {
2745 relation::create(state, shell, user, session, id, headers, data, r).await
2746 }
2747 },
2748 ),
2749 );
2750 let (r_edit, r_update) = (rel.clone(), rel.clone());
2751 routes = routes.route(
2752 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
2753 get(
2754 move |state: State<AdminState>,
2755 shell: Extension<Shell>,
2756 ids: Path<(String, String)>| {
2757 let r = r_edit.clone();
2758 async move { relation::edit_form(state, shell, ids, r).await }
2759 },
2760 )
2761 .post(
2762 move |state: State<AdminState>,
2763 shell: Extension<Shell>,
2764 user: Extension<AuthenticatedUser>,
2765 session: Extension<session::SessionHandle>,
2766 ids: Path<(String, String)>,
2767 headers: axum::http::HeaderMap,
2768 data: Form<HashMap<String, String>>| {
2769 let r = r_update.clone();
2770 async move {
2771 relation::update(state, shell, user, session, ids, headers, data, r).await
2772 }
2773 },
2774 ),
2775 );
2776 let r_delete = rel.clone();
2777 routes = routes.route(
2778 &format!("{base}/{{id}}/{name}/delete"),
2779 post(
2780 move |state: State<AdminState>,
2781 shell: Extension<Shell>,
2782 user: Extension<AuthenticatedUser>,
2783 session: Extension<session::SessionHandle>,
2784 id: Path<String>,
2785 headers: axum::http::HeaderMap,
2786 pairs: Form<Vec<(String, String)>>| {
2787 let r = r_delete.clone();
2788 async move {
2789 relation::delete(state, shell, user, session, id, headers, pairs, r).await
2790 }
2791 },
2792 ),
2793 );
2794 if rel.reorderable() {
2795 let r_reorder = rel.clone();
2796 routes = routes.route(
2797 &format!("{base}/{{id}}/{name}/reorder"),
2798 post(
2799 move |state: State<AdminState>,
2800 shell: Extension<Shell>,
2801 user: Extension<AuthenticatedUser>,
2802 session: Extension<session::SessionHandle>,
2803 id: Path<String>,
2804 headers: axum::http::HeaderMap,
2805 pairs: Form<Vec<(String, String)>>| {
2806 let r = r_reorder.clone();
2807 async move {
2808 relation::reorder(state, shell, user, session, id, headers, pairs, r)
2809 .await
2810 }
2811 },
2812 ),
2813 );
2814 }
2815 if let Some(permission) = &rel.config.permission {
2816 routes = guard_with_permission(routes, permission);
2817 }
2818 router = router.merge(routes);
2819 }
2820
2821 if let Some(permission) = &resource.permission {
2823 router = guard_with_permission(router, permission);
2824 }
2825 (router, prepared_form)
2826}
2827
2828fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
2833 let needed: Arc<str> = Arc::from(permission);
2834 router.route_layer(middleware::from_fn(
2835 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
2836 let needed = needed.clone();
2837 async move {
2838 if user.allows(&needed) {
2839 next.run(req).await
2840 } else {
2841 forbidden()
2842 }
2843 }
2844 },
2845 ))
2846}
2847
2848async fn require_auth(
2854 State(state): State<AdminState>,
2855 jar: CookieJar,
2856 request: Request,
2857 next: Next,
2858) -> Response {
2859 let login = format!("{}/login", state.admin_path);
2860 let client_ctx = request
2861 .extensions()
2862 .get::<RequestContext>()
2863 .cloned()
2864 .unwrap_or_default();
2865 let mut ended: Option<laterite_auth::SessionEnd> = None;
2870 let live = match jar.get(SESSION_COOKIE) {
2871 Some(cookie) => {
2872 let token = cookie.value().to_string();
2873 match state.auth.resolve_session(&token).await {
2874 Ok(resolved) => Some((token, resolved)),
2875 Err(_) => {
2876 ended = state.auth.session_end(&token).await.ok().flatten();
2877 None
2878 }
2879 }
2880 }
2881 None => None,
2882 };
2883 let htmx = request.headers().contains_key("hx-request");
2884 let (token, resolved, recalled) = match live {
2885 Some((token, resolved)) => (token, resolved, None),
2886 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2889 return signed_out(&state, jar, &login, ended, htmx);
2890 }
2891 None => match recall(&state, &jar, &client_ctx).await {
2892 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2893 None => {
2894 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2898 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2899 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2900 }
2901 }
2902 return signed_out(&state, jar, &login, ended, htmx);
2903 }
2904 },
2905 };
2906 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2907
2908 let mut request = request;
2913 let safe = session::is_safe_method(request.method());
2914 let mut deferred: Option<session::CsrfPending> = None;
2916 if !safe {
2917 let (parts, body) = request.into_parts();
2918 let (body, submitted) = if session::is_multipart(&parts.headers) {
2925 let token = session::header_token(&parts.headers)
2926 .or_else(|| session::query_token(parts.uri.query()));
2927 if token.is_none() {
2932 deferred = Some(session::CsrfPending::default());
2933 }
2934 (body, token)
2935 } else {
2936 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2937 .await
2938 .unwrap_or_default();
2939 let token = session::submitted_token(&parts.headers, &bytes);
2940 (Body::from(bytes), token)
2941 };
2942 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2943 {
2944 tracing::warn!(
2945 user_id = resolved.identity.user.id,
2946 "admin CSRF check failed"
2947 );
2948 return error::csrf_rejected();
2949 }
2950 request = Request::from_parts(parts, body);
2951 }
2952
2953 let user = resolved.identity;
2954 let acting_user_id = user.user.id;
2956 let path = request.uri().path().to_string();
2957 let preferences = format!("{}/preferences", state.admin_path);
2960 if !path.starts_with(&preferences)
2961 && path != format!("{}/logout", state.admin_path)
2962 && state
2963 .auth
2964 .must_change_password(user.user.id)
2965 .await
2966 .unwrap_or(false)
2967 {
2968 let to = format!("{preferences}#password");
2969 let response = if htmx {
2970 (
2971 [("HX-Redirect", to.as_str())],
2972 axum::http::StatusCode::NO_CONTENT,
2973 )
2974 .into_response()
2975 } else {
2976 Redirect::to(&to).into_response()
2977 };
2978 return with_recall_cookies(response, recalled, jar, token, &state);
2979 }
2980 let flash = if safe {
2983 handle.take_flash()
2984 } else {
2985 Vec::new()
2986 };
2987 let accept_language = request
2991 .headers()
2992 .get("accept-language")
2993 .and_then(|v| v.to_str().ok());
2994 let serveable = state.catalogs.locales();
2995 let chain = resolve_locale_chain(
2996 user.user.locale.as_deref(),
2997 accept_language,
2998 &state.default_locale,
2999 &serveable,
3000 );
3001 let i18n = Translator::with_chain(chain, state.catalogs.clone());
3002 let translator = i18n.clone();
3005 let own_order =
3007 laterite_auth::store::user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
3008 .await
3009 .unwrap_or_default();
3010 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
3011 &state.nav,
3012 &state.settings,
3013 &state.admin_path,
3014 &user.permissions,
3015 own_order.as_deref(),
3016 &path,
3017 &i18n,
3018 icons::Icons::new(&state.icons, &state.sprite_url),
3019 );
3020 let brand = state.brand().await;
3021 let shell = Shell::new(
3022 &state.admin_path,
3023 brand,
3024 &visible_nav,
3025 &user,
3026 state.timezone,
3027 sidebar,
3028 active_nav.as_deref(),
3029 handle.csrf_token(),
3030 flash,
3031 i18n,
3032 state.asset_urls.clone(),
3033 state.origin.to_string(),
3034 state.icons.clone(),
3035 state.sprite_url.clone(),
3036 );
3037 request.extensions_mut().insert(user);
3038 request.extensions_mut().insert(shell);
3039 request.extensions_mut().insert(handle.clone());
3040 if let Some(pending) = &deferred {
3042 request.extensions_mut().insert(pending.clone());
3043 }
3044 let mut response = next.run(request).await;
3045
3046 if let Some(pending) = &deferred {
3051 if !pending.was_satisfied() {
3052 tracing::error!(
3053 user_id = acting_user_id,
3054 "a multipart route returned without verifying its request token"
3055 );
3056 return error::csrf_rejected();
3057 }
3058 }
3059
3060 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
3064 response = error::localized_error(meta.kind, &translator);
3065 }
3066
3067 if let Some(blob) = handle.dirty_blob() {
3070 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
3071 tracing::error!(error = %e, "persisting admin session failed");
3072 }
3073 }
3074 with_recall_cookies(response, recalled, jar, token, &state)
3075}
3076
3077fn with_recall_cookies(
3081 response: Response,
3082 recalled: Option<laterite_auth::RememberCredential>,
3083 jar: CookieJar,
3084 token: String,
3085 state: &AdminState,
3086) -> Response {
3087 match recalled {
3088 Some(credential) => {
3089 let jar = jar
3090 .add(session_cookie(
3091 token,
3092 &state.cookie_path,
3093 state.secure_cookie,
3094 ))
3095 .add(remember_cookie(
3096 credential,
3097 &state.cookie_path,
3098 state.secure_cookie,
3099 ));
3100 (jar, response).into_response()
3101 }
3102 None => response,
3103 }
3104}
3105
3106async fn recall(
3110 state: &AdminState,
3111 jar: &CookieJar,
3112 ctx: &RequestContext,
3113) -> Option<(
3114 String,
3115 laterite_auth::ResolvedSession,
3116 laterite_auth::RememberCredential,
3117)> {
3118 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
3119 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
3120 let token = recalled.session.token.clone();
3121 let resolved = state.auth.resolve_session(&token).await.ok()?;
3122 Some((token, resolved, recalled.remember))
3123}
3124
3125const MAX_FORM_BYTES: usize = 1024 * 1024;
3128
3129async fn capture_client(
3137 State(state): State<AdminState>,
3138 mut request: Request,
3139 next: Next,
3140) -> Response {
3141 let peer = request
3142 .extensions()
3143 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3144 .map(|info| info.0);
3145 let headers = request.headers();
3146 let ctx = RequestContext {
3147 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3148 user_agent: clientip::user_agent(headers),
3149 };
3150 request.extensions_mut().insert(ctx);
3151 next.run(request).await
3152}
3153
3154async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3155 if !session::is_safe_method(request.method())
3156 && !session::origin_ok(request.headers(), &state.origin)
3157 {
3158 tracing::warn!(
3159 path = request.uri().path(),
3160 "admin CSRF origin check failed"
3161 );
3162 return error::csrf_rejected();
3163 }
3164 next.run(request).await
3165}
3166
3167#[derive(Deserialize)]
3168struct LoginQuery {
3169 ended: Option<String>,
3170}
3171
3172async fn login_form(
3173 State(state): State<AdminState>,
3174 headers: HeaderMap,
3175 Query(query): Query<LoginQuery>,
3176) -> Response {
3177 match state.auth.has_any_operator().await {
3179 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
3180 Ok(true) => {
3181 let i18n = pre_auth_translator(&state, &headers);
3182 let notice = query
3183 .ended
3184 .as_deref()
3185 .and_then(end_notice)
3186 .map(|text| i18n.t(&text));
3187 render(LoginTemplate {
3188 base: state.admin_path.to_string(),
3189 asset_urls: state.asset_urls.clone(),
3190 brand: state.brand().await,
3191 error: None,
3192 notice,
3193 i18n,
3194 })
3195 }
3196 Err(_) => render_error(),
3197 }
3198}
3199
3200#[derive(Deserialize)]
3201struct LoginForm {
3202 username: String,
3203 password: String,
3204 #[serde(default)]
3206 remember: Option<String>,
3207}
3208
3209async fn login_submit(
3210 State(state): State<AdminState>,
3211 jar: CookieJar,
3212 headers: HeaderMap,
3213 Extension(ctx): Extension<RequestContext>,
3214 Form(form): Form<LoginForm>,
3215) -> Response {
3216 match state
3217 .auth
3218 .authenticate(&form.username, &form.password, &ctx)
3219 .await
3220 {
3221 Ok(session) => {
3222 let wants_remember = form.remember.is_some();
3223 let user_id = session.user_id;
3224 let mut jar = jar.add(session_cookie(
3225 session.token,
3226 &state.cookie_path,
3227 state.secure_cookie,
3228 ));
3229 if wants_remember {
3230 match state.auth.issue_remember(user_id).await {
3231 Ok(credential) => {
3232 jar = jar.add(remember_cookie(
3233 credential,
3234 &state.cookie_path,
3235 state.secure_cookie,
3236 ));
3237 }
3238 Err(e) => {
3240 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
3241 }
3242 }
3243 }
3244 (jar, Redirect::to(&state.admin_path)).into_response()
3245 }
3246 Err(_) => {
3247 let i18n = pre_auth_translator(&state, &headers);
3248 render(LoginTemplate {
3249 base: state.admin_path.to_string(),
3250 asset_urls: state.asset_urls.clone(),
3251 brand: state.brand().await,
3252 error: Some(i18n.t(&t!("Invalid username or password."))),
3253 notice: None,
3254 i18n,
3255 })
3256 }
3257 }
3258}
3259
3260fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
3270 Cookie::build((SESSION_COOKIE, token))
3271 .path(admin_path.to_string())
3272 .http_only(true)
3273 .secure(secure)
3274 .same_site(SameSite::Lax)
3275 .build()
3276}
3277
3278fn remember_cookie(
3281 credential: laterite_auth::RememberCredential,
3282 admin_path: &str,
3283 secure: bool,
3284) -> Cookie<'static> {
3285 let seconds = (credential.expires_at - chrono::Utc::now())
3286 .num_seconds()
3287 .max(0);
3288 Cookie::build((REMEMBER_COOKIE, credential.cookie))
3289 .path(admin_path.to_string())
3290 .http_only(true)
3291 .secure(secure)
3292 .same_site(SameSite::Lax)
3293 .max_age(cookie::time::Duration::seconds(seconds))
3294 .build()
3295}
3296
3297fn signed_out(
3304 state: &AdminState,
3305 jar: CookieJar,
3306 login: &str,
3307 ended: Option<laterite_auth::SessionEnd>,
3308 htmx: bool,
3309) -> Response {
3310 let to = match ended.map(laterite_auth::SessionEnd::code) {
3311 Some(code) => format!("{login}?ended={code}"),
3312 None => login.to_string(),
3313 };
3314 let jar = jar
3315 .remove(
3316 Cookie::build((SESSION_COOKIE, ""))
3317 .path(state.cookie_path.to_string())
3318 .build(),
3319 )
3320 .remove(remember_removal(&state.cookie_path));
3321 if htmx {
3322 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
3323 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
3324 response.headers_mut().insert("hx-redirect", value);
3325 }
3326 return response;
3327 }
3328 (jar, Redirect::to(&to)).into_response()
3329}
3330
3331fn end_notice(code: &str) -> Option<Text> {
3334 Some(match code {
3335 "password_changed" => {
3336 t!("Your password was changed, so you were signed out. Sign in with the new one.")
3337 }
3338 "deactivated" => t!("This account was deactivated."),
3339 "signed_out_elsewhere" => t!("You were signed out from another device."),
3340 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3341 _ => return None,
3342 })
3343}
3344
3345fn remember_removal(admin_path: &str) -> Cookie<'static> {
3347 Cookie::build((REMEMBER_COOKIE, ""))
3348 .path(admin_path.to_string())
3349 .build()
3350}
3351
3352#[derive(Deserialize)]
3353struct SetupForm {
3354 username: String,
3355 first_name: String,
3356 last_name: String,
3357 email: String,
3358 password: String,
3359 timezone: String,
3360}
3361
3362async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3365 match state.auth.has_any_operator().await {
3366 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3367 Ok(false) => render(setup_view(
3368 &state.admin_path,
3369 state.brand().await,
3370 state.timezone,
3371 None,
3372 pre_auth_translator(&state, &headers),
3373 state.asset_urls.clone(),
3374 state.auth.password_policy().min_length,
3375 )),
3376 Err(_) => render_error(),
3377 }
3378}
3379
3380async fn setup_submit(
3381 State(state): State<AdminState>,
3382 jar: CookieJar,
3383 headers: HeaderMap,
3384 client: Option<Extension<RequestContext>>,
3385 Form(form): Form<SetupForm>,
3386) -> Response {
3387 match state.auth.has_any_operator().await {
3389 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3390 Ok(false) => {}
3391 Err(_) => return render_error(),
3392 }
3393
3394 let username = form.username.trim();
3395 let email = form.email.trim();
3396 let first_name = form.first_name.trim();
3397 let last_name = form.last_name.trim();
3398 let tz = form.timezone.trim();
3399 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3400 {
3401 return render(setup_view(
3402 &state.admin_path,
3403 state.brand().await,
3404 state.timezone,
3405 Some(t!(
3406 "Username, first name, email, and password are all required."
3407 )),
3408 pre_auth_translator(&state, &headers),
3409 state.asset_urls.clone(),
3410 state.auth.password_policy().min_length,
3411 ));
3412 }
3413 if tz.parse::<Tz>().is_err() {
3415 return render(setup_view(
3416 &state.admin_path,
3417 state.brand().await,
3418 state.timezone,
3419 Some(t!("That is not a recognised timezone.")),
3420 pre_auth_translator(&state, &headers),
3421 state.asset_urls.clone(),
3422 state.auth.password_policy().min_length,
3423 ));
3424 }
3425
3426 let min_length = state.auth.password_policy().min_length;
3427 if form.password.chars().count() < min_length {
3428 return render(setup_view(
3429 &state.admin_path,
3430 state.brand().await,
3431 state.timezone,
3432 Some(t!(
3433 "A password needs at least {n} characters.",
3434 n = min_length as i64
3435 )),
3436 pre_auth_translator(&state, &headers),
3437 state.asset_urls.clone(),
3438 state.auth.password_policy().min_length,
3439 ));
3440 }
3441 let new = NewOperator {
3442 username,
3443 email,
3444 first_name,
3445 last_name: (!last_name.is_empty()).then_some(last_name),
3446 password: &form.password,
3447 timezone: Some(tz),
3448 };
3449 let id = match state.auth.create_superuser(new).await {
3450 Ok(id) => id,
3451 Err(_) => {
3452 return render(setup_view(
3453 &state.admin_path,
3454 state.brand().await,
3455 state.timezone,
3456 Some(t!(
3457 "Could not create the account. The username or email may already be taken."
3458 )),
3459 pre_auth_translator(&state, &headers),
3460 state.asset_urls.clone(),
3461 state.auth.password_policy().min_length,
3462 ));
3463 }
3464 };
3465 let actor = laterite_core::Actor::system("first-run setup");
3468 let target = id.to_string();
3469 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
3470 "backend_user",
3471 &target,
3472 Some(username),
3473 );
3474 if let Err(e) = state.auth.record_audit(entry).await {
3475 tracing::error!(error = %e, "failed to write audit log entry");
3476 }
3477
3478 match state
3480 .auth
3481 .authenticate(
3482 username,
3483 &form.password,
3484 &client.map(|c| c.0).unwrap_or_default(),
3485 )
3486 .await
3487 {
3488 Ok(session) => {
3489 let cookie = session_cookie(session.token, &state.cookie_path, state.secure_cookie);
3490 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
3491 }
3492 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3493 }
3494}
3495
3496fn setup_view(
3499 admin_path: &str,
3500 brand: String,
3501 default_tz: Tz,
3502 error: Option<Text>,
3503 i18n: Translator,
3504 asset_urls: Arc<AssetUrls>,
3505 min_password: usize,
3506) -> SetupTemplate {
3507 let default_name = default_tz.name();
3508 let zones = TZ_VARIANTS
3509 .iter()
3510 .filter(|tz| zone_offered(tz.name()))
3511 .map(|tz| TzOption {
3512 name: tz.name().to_string(),
3513 selected: tz.name() == default_name,
3514 })
3515 .collect();
3516 SetupTemplate {
3517 base: admin_path.to_string(),
3518 asset_urls,
3519 brand,
3520 zones,
3521 error: error.map(|e| i18n.t(&e)),
3522 min_password: min_password as i64,
3523 i18n,
3524 }
3525}
3526
3527async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
3528 if let Some(cookie) = jar.get(SESSION_COOKIE) {
3529 let _ = state.auth.logout(cookie.value()).await;
3530 }
3531 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3534 let _ = state.auth.revoke_remember(cookie.value()).await;
3535 }
3536 let removal = Cookie::build((SESSION_COOKIE, ""))
3537 .path(state.cookie_path.to_string())
3538 .build();
3539 (
3540 jar.remove(removal)
3541 .remove(remember_removal(&state.cookie_path)),
3542 Redirect::to(&format!("{}/login", state.admin_path)),
3543 )
3544 .into_response()
3545}
3546
3547async fn dashboard(
3548 Extension(shell): Extension<Shell>,
3549 Extension(user): Extension<AuthenticatedUser>,
3550) -> Response {
3551 render(DashboardTemplate {
3552 username: user.user.username,
3553 shell,
3554 })
3555}
3556
3557fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
3560 match &item.permission {
3561 None => true,
3562 Some(p) => perms.allows(p),
3563 }
3564}
3565
3566fn visible_settings(
3570 items: &[settings::SettingsItem],
3571 perms: &PermissionSet,
3572) -> Vec<settings::SettingsItem> {
3573 items
3574 .iter()
3575 .filter(|item| operator_can_see(item, perms))
3576 .cloned()
3577 .collect()
3578}
3579
3580async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
3581 settings::index(shell)
3582}
3583
3584async fn settings_edit(
3585 state: AdminState,
3586 shell: Shell,
3587 user: AuthenticatedUser,
3588 code: String,
3589) -> Response {
3590 let items = visible_settings(&state.settings, &user.permissions);
3593 match items
3594 .iter()
3595 .find(|item| item.code == code && item.link.is_none())
3596 {
3597 Some(item) => settings::edit_form(&state, item, shell, &user).await,
3598 None => not_found(),
3599 }
3600}
3601
3602async fn settings_update(
3603 state: AdminState,
3604 shell: Shell,
3605 user: AuthenticatedUser,
3606 session: session::SessionHandle,
3607 code: String,
3608 data: HashMap<String, String>,
3609) -> Response {
3610 let items = visible_settings(&state.settings, &user.permissions);
3611 match items
3612 .iter()
3613 .find(|item| item.code == code && item.link.is_none())
3614 {
3615 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
3616 None => not_found(),
3617 }
3618}
3619
3620async fn preferences_form(
3622 State(state): State<AdminState>,
3623 Extension(shell): Extension<Shell>,
3624 Extension(user): Extension<AuthenticatedUser>,
3625 jar: CookieJar,
3626) -> Response {
3627 let account = account_security(&state, &user, &shell, &jar).await;
3628 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
3629 .into_iter()
3630 .filter(|i| i.per_operator)
3631 .map(|i| PreferenceLink {
3632 label: shell.tt(&i.label),
3633 description: shell.tt(&i.description),
3634 href: i.path(&state.admin_path),
3635 })
3636 .collect();
3637 render(preferences_view(
3638 &shell,
3639 &user,
3640 state.timezone,
3641 &state.default_locale,
3642 &offered_locales(&state.catalogs),
3643 account,
3644 state.auth.password_policy().min_length,
3645 items,
3646 ))
3647}
3648
3649struct PreferenceLink {
3651 label: String,
3652 description: String,
3653 href: String,
3654}
3655
3656#[derive(Deserialize)]
3657struct RevokeSessionForm {
3658 id: String,
3659}
3660
3661async fn session_revoke(
3663 State(state): State<AdminState>,
3664 Extension(user): Extension<AuthenticatedUser>,
3665 Extension(session): Extension<session::SessionHandle>,
3666 Form(form): Form<RevokeSessionForm>,
3667) -> Response {
3668 match state.auth.revoke_session(user.user.id, &form.id).await {
3669 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
3670 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
3671 Err(e) => {
3672 tracing::error!(error = %e, "revoking a session failed");
3673 session.push_flash(
3674 FlashLevel::Error,
3675 t!("That session could not be signed out."),
3676 );
3677 }
3678 }
3679 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3680}
3681
3682#[derive(Deserialize)]
3684struct MenuOrderForm {
3685 #[serde(default)]
3686 order: String,
3687}
3688
3689async fn menu_order_update(
3694 State(state): State<AdminState>,
3695 Extension(user): Extension<AuthenticatedUser>,
3696 Extension(session): Extension<session::SessionHandle>,
3697 headers: axum::http::HeaderMap,
3698 Form(form): Form<MenuOrderForm>,
3699) -> Response {
3700 let order: Vec<&str> = form
3701 .order
3702 .split(',')
3703 .map(str::trim)
3704 .filter(|k| !k.is_empty() && k.len() <= 200)
3705 .take(100)
3706 .collect();
3707 let result = if order.is_empty() {
3708 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
3709 .await
3710 } else {
3711 laterite_auth::store::set_user_preference(
3712 &state.db,
3713 user.user.id,
3714 nav::ORDER_PREFERENCE,
3715 &order.join(","),
3716 )
3717 .await
3718 };
3719 if let Err(e) = result {
3720 tracing::error!(error = %e, "storing the menu order failed");
3721 return render_error();
3722 }
3723 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
3724 return axum::http::StatusCode::NO_CONTENT.into_response();
3725 }
3726 session.push_flash(
3727 FlashLevel::Success,
3728 if order.is_empty() {
3729 t!("The menu is back in its default order.")
3730 } else {
3731 t!("Saved.")
3732 },
3733 );
3734 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3735}
3736
3737async fn session_revoke_others(
3738 State(state): State<AdminState>,
3739 Extension(user): Extension<AuthenticatedUser>,
3740 Extension(session): Extension<session::SessionHandle>,
3741 jar: CookieJar,
3742) -> Response {
3743 let token = jar
3744 .get(SESSION_COOKIE)
3745 .map(|c| c.value().to_string())
3746 .unwrap_or_default();
3747 match state.auth.sign_out_everywhere(user.user.id, &token).await {
3748 Ok(_) => session.push_flash(
3749 FlashLevel::Success,
3750 t!("Every other session was signed out."),
3751 ),
3752 Err(e) => {
3753 tracing::error!(error = %e, "signing out other sessions failed");
3754 session.push_flash(
3755 FlashLevel::Error,
3756 t!("Those sessions could not be signed out."),
3757 );
3758 }
3759 }
3760 (
3763 jar.remove(remember_removal(&state.cookie_path)),
3764 Redirect::to(&format!("{}/preferences", state.admin_path)),
3765 )
3766 .into_response()
3767}
3768
3769#[derive(Deserialize)]
3770struct PasswordForm {
3771 current_password: String,
3772 new_password: String,
3773 confirm_password: String,
3774}
3775
3776async fn password_update(
3781 State(state): State<AdminState>,
3782 Extension(user): Extension<AuthenticatedUser>,
3783 Extension(session): Extension<session::SessionHandle>,
3784 Extension(ctx): Extension<RequestContext>,
3785 jar: CookieJar,
3786 Form(form): Form<PasswordForm>,
3787) -> Response {
3788 let back = format!("{}/preferences#password", state.admin_path);
3789 let refuse = |message: Text| {
3790 session.push_flash(FlashLevel::Error, message);
3791 Redirect::to(&back).into_response()
3792 };
3793 if form.new_password != form.confirm_password {
3794 return refuse(t!("The new passwords do not match."));
3795 }
3796 let min_length = state.auth.password_policy().min_length;
3797 if form.new_password.chars().count() < min_length {
3798 return refuse(t!(
3799 "A password needs at least {n} characters.",
3800 n = min_length as i64
3801 ));
3802 }
3803 let remembered_here = match jar.get(REMEMBER_COOKIE) {
3806 Some(cookie) => {
3807 state
3808 .auth
3809 .remember_holder(cookie.value())
3810 .await
3811 .ok()
3812 .flatten()
3813 == Some(user.user.id)
3814 }
3815 None => false,
3816 };
3817 let token = jar
3818 .get(SESSION_COOKIE)
3819 .map(|c| c.value().to_string())
3820 .unwrap_or_default();
3821 let changed = state
3822 .auth
3823 .change_own_password(
3824 user.user.id,
3825 &form.current_password,
3826 &form.new_password,
3827 &token,
3828 &ctx,
3829 )
3830 .await;
3831 match changed {
3832 Ok(()) => {}
3833 Err(laterite_auth::AuthError::InvalidCredentials) => {
3834 return refuse(t!("Your current password is not correct."));
3835 }
3836 Err(laterite_auth::AuthError::TooManyAttempts) => {
3837 return refuse(t!("Too many attempts. Try again later."));
3838 }
3839 Err(e) => {
3840 tracing::error!(error = %e, "changing a password failed");
3841 return refuse(t!("The password could not be changed."));
3842 }
3843 }
3844 session.push_flash_sticky(
3845 FlashLevel::Success,
3846 t!("Password changed. Every other device was signed out."),
3847 );
3848 let jar = if remembered_here {
3849 match state.auth.issue_remember(user.user.id).await {
3850 Ok(credential) => jar.add(remember_cookie(
3851 credential,
3852 &state.cookie_path,
3853 state.secure_cookie,
3854 )),
3855 Err(e) => {
3856 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
3857 jar.remove(remember_removal(&state.cookie_path))
3858 }
3859 }
3860 } else if jar.get(REMEMBER_COOKIE).is_some() {
3861 jar.remove(remember_removal(&state.cookie_path))
3864 } else {
3865 jar
3866 };
3867 (
3868 jar,
3869 Redirect::to(&format!("{}/preferences", state.admin_path)),
3870 )
3871 .into_response()
3872}
3873
3874#[derive(Deserialize)]
3875struct PreferencesForm {
3876 timezone: String,
3877 #[serde(default)]
3879 locale: String,
3880}
3881
3882async fn preferences_update(
3883 State(state): State<AdminState>,
3884 Extension(user): Extension<AuthenticatedUser>,
3885 Extension(session): Extension<session::SessionHandle>,
3886 Form(form): Form<PreferencesForm>,
3887) -> Response {
3888 let back = format!("{}/preferences", state.admin_path);
3889 let offered = offered_locales(&state.catalogs);
3890 let tz = form.timezone.trim();
3892 let tz_stored = if tz.is_empty() {
3893 None
3894 } else if tz.parse::<Tz>().is_ok() {
3895 Some(tz)
3896 } else {
3897 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
3898 return Redirect::to(&back).into_response();
3899 };
3900 let loc = form.locale.trim();
3901 let loc_stored = if loc.is_empty() {
3902 None
3903 } else if offered.iter().any(|l| l == loc) {
3904 Some(loc)
3905 } else {
3906 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
3907 return Redirect::to(&back).into_response();
3908 };
3909 if state
3910 .auth
3911 .set_user_timezone(user.user.id, tz_stored)
3912 .await
3913 .is_err()
3914 {
3915 return render_error();
3916 }
3917 match state.auth.set_user_locale(user.user.id, loc_stored).await {
3918 Ok(()) => {
3919 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
3920 Redirect::to(&back).into_response()
3921 }
3922 Err(_) => render_error(),
3923 }
3924}
3925
3926#[allow(clippy::too_many_arguments)]
3930fn preferences_view(
3931 shell: &Shell,
3932 user: &AuthenticatedUser,
3933 default_tz: Tz,
3934 default_locale: &str,
3935 offered: &[String],
3936 account: AccountSecurity,
3937 min_password: usize,
3938 items: Vec<PreferenceLink>,
3939) -> PreferencesTemplate {
3940 let current = user.user.timezone.as_deref();
3941 let zones = TZ_VARIANTS
3942 .iter()
3943 .filter(|tz| zone_offered(tz.name()))
3944 .map(|tz| TzOption {
3945 name: tz.name().to_string(),
3946 selected: current == Some(tz.name()),
3947 })
3948 .collect();
3949 let current_locale = user.user.locale.as_deref();
3950 let locales = offered
3951 .iter()
3952 .map(|code| LocaleOption {
3953 name: locale_name(code),
3954 selected: current_locale == Some(code.as_str()),
3955 code: code.clone(),
3956 })
3957 .collect();
3958 PreferencesTemplate {
3959 shell: shell.clone(),
3960 zones,
3961 effective_tz: shell.tz.name().to_string(),
3962 default_tz: default_tz.name().to_string(),
3963 inherits: current.is_none(),
3964 locales,
3965 default_locale: locale_name(default_locale),
3966 inherits_locale: current_locale.is_none(),
3967 sessions: account.sessions,
3968 password_changed: account.password_changed,
3969 must_change: account.must_change,
3970 min_password: min_password as i64,
3971 items,
3972 }
3973}
3974
3975struct AccountSecurity {
3978 sessions: Vec<SessionRow>,
3979 password_changed: Option<String>,
3980 must_change: bool,
3982}
3983
3984async fn account_security(
3985 state: &AdminState,
3986 user: &AuthenticatedUser,
3987 shell: &Shell,
3988 jar: &CookieJar,
3989) -> AccountSecurity {
3990 let locale = list::date_locale(shell.locale());
3991 let password_changed = match state.auth.password_changed_at(user.user.id).await {
3992 Ok(at) => {
3993 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
3994 }
3995 Err(e) => {
3996 tracing::error!(error = %e, "reading the password change time failed");
3997 None
3998 }
3999 };
4000 AccountSecurity {
4001 sessions: session_rows(state, user, shell, jar).await,
4002 password_changed,
4003 must_change: state
4004 .auth
4005 .must_change_password(user.user.id)
4006 .await
4007 .unwrap_or(false),
4008 }
4009}
4010
4011async fn session_rows(
4014 state: &AdminState,
4015 user: &AuthenticatedUser,
4016 shell: &Shell,
4017 jar: &CookieJar,
4018) -> Vec<SessionRow> {
4019 let token = jar
4020 .get(SESSION_COOKIE)
4021 .map(|c| c.value().to_string())
4022 .unwrap_or_default();
4023 let locale = list::date_locale(shell.locale());
4024 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
4025 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
4026 };
4027 match state.auth.list_sessions(user.user.id, &token).await {
4028 Ok(sessions) => sessions
4029 .into_iter()
4030 .map(|s| SessionRow {
4031 id: s.id,
4032 started: stamp(s.created_at),
4033 last_seen: stamp(s.last_seen_at),
4034 expires: stamp(s.expires_at),
4035 device: clientip::describe(s.user_agent.as_deref())
4036 .or(s.user_agent)
4037 .unwrap_or_else(|| "-".to_string()),
4038 ip_address: s.ip_address.unwrap_or_default(),
4039 current: s.current,
4040 })
4041 .collect(),
4042 Err(e) => {
4043 tracing::error!(error = %e, "listing sessions failed");
4044 Vec::new()
4045 }
4046 }
4047}
4048
4049fn builtin_resources() -> Vec<Resource> {
4051 vec![
4052 Resource {
4053 base_path: "/users".to_string(),
4054 nav_label: "Administrators".into(),
4055 list: backend_users_list_config(),
4056 form: None,
4057 permission: Some("backend.manage_users".to_string()),
4058 import: None,
4059 palette: None,
4060 relations: Vec::new(),
4061 menu: nav::Placement::default(),
4062 },
4063 Resource {
4064 base_path: "/roles".to_string(),
4065 nav_label: "Roles".into(),
4066 list: roles_list_config(),
4067 form: None,
4070 permission: Some("backend.manage_roles".to_string()),
4071 import: None,
4072 palette: None,
4073 relations: Vec::new(),
4074 menu: nav::Placement::default(),
4075 },
4076 resource!("descriptors/audit-log.yaml"),
4077 resource!("descriptors/access-log.yaml"),
4078 resource!("descriptors/request-log.yaml"),
4079 ]
4080}
4081
4082fn builtin_settings() -> Vec<settings::SettingsItem> {
4086 vec![
4087 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
4088 .description("Manage backend administrator accounts.")
4089 .category("Users")
4090 .order(10)
4091 .icon("users")
4092 .permission("backend.manage_users")
4093 .link("/users"),
4094 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
4095 .description("Manage roles and their permissions.")
4096 .category("Users")
4097 .order(20)
4098 .icon("shield")
4099 .permission("backend.manage_roles")
4100 .link("/roles"),
4101 settings::brand::settings_item(),
4102 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
4103 .description("Enable or disable installed plugins.")
4104 .category("System")
4105 .order(10)
4106 .icon("plug")
4107 .permission(plugins::MANAGE_PERMISSION)
4108 .link("/plugins"),
4109 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
4110 .description("Review the record of administrative changes.")
4111 .category("System")
4112 .order(20)
4113 .icon("history")
4114 .permission("backend.view_audit_log")
4115 .link("/audit-log"),
4116 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
4117 .description("Review sign-ins, failures and lockouts.")
4118 .category("System")
4119 .order(30)
4120 .icon("log-in")
4121 .permission("backend.view_access_log")
4122 .link("/access-log"),
4123 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
4124 .description("Review the URLs the panel could not serve.")
4125 .category("System")
4126 .order(31)
4127 .icon("unlink")
4128 .permission("backend.view_request_log")
4129 .link("/request-log"),
4130 ]
4131}
4132
4133fn backend_users_list_config() -> list::ListConfig {
4134 list::ListConfig {
4135 entity: "backend_users".to_string(),
4136 title: "Administrators".into(),
4137 columns: vec![
4138 list::ListColumn::new("username", "Username"),
4139 list::ListColumn::new("email", "Email"),
4140 list::ListColumn::new("first_name", "First name"),
4141 list::ListColumn::new("last_name", "Last name"),
4142 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
4143 list::ListColumn::new("is_active", "Active").yes_no(),
4144 list::ListColumn::new("created_at", "Created").datetime(),
4145 ],
4146 order_by: "created_at".to_string(),
4147 creatable: true,
4150 edit_base: Some("/users".to_string()),
4151 filters: vec![
4152 list::ListFilter::boolean("is_active", "Active"),
4153 list::ListFilter::boolean("is_superuser", "Superuser"),
4154 ],
4155 ..Default::default()
4159 }
4160}
4161
4162fn roles_list_config() -> list::ListConfig {
4163 list::ListConfig {
4164 entity: "backend_roles".to_string(),
4165 title: "Roles".into(),
4166 columns: vec![
4167 list::ListColumn::new("code", "Code"),
4168 list::ListColumn::new("name", "Name"),
4169 list::ListColumn::new("created_at", "Created").datetime(),
4170 ],
4171 order_by: "created_at".to_string(),
4172 edit_base: Some("/roles".to_string()),
4173 creatable: true,
4174 deletable: true,
4175 ..Default::default()
4176 }
4177}
4178
4179#[derive(Template)]
4180#[template(path = "login.html")]
4181struct LoginTemplate {
4182 base: String,
4184 asset_urls: Arc<AssetUrls>,
4186 brand: String,
4187 error: Option<String>,
4188 notice: Option<String>,
4190 i18n: Translator,
4193}
4194
4195fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
4198 match urls.get(key) {
4199 Some(path) => format!("{base}/assets/{path}"),
4200 None => {
4201 debug_assert!(false, "asset key `{key}` is not registered");
4202 format!("{base}/assets/{key}")
4203 }
4204 }
4205}
4206
4207impl LoginTemplate {
4208 fn asset(&self, key: &str) -> String {
4209 preauth_asset(&self.asset_urls, &self.base, key)
4210 }
4211 fn t(&self, source: &str) -> String {
4212 self.i18n.t(&Text::dynamic(source))
4213 }
4214 fn locale(&self) -> &str {
4215 self.i18n.locale()
4216 }
4217}
4218
4219#[derive(Template)]
4220#[template(path = "dashboard.html")]
4221struct DashboardTemplate {
4222 shell: Shell,
4223 username: String,
4224}
4225
4226#[derive(Template)]
4227#[template(path = "setup.html")]
4228struct SetupTemplate {
4229 base: String,
4231 asset_urls: Arc<AssetUrls>,
4233 brand: String,
4234 zones: Vec<TzOption>,
4235 error: Option<String>,
4236 i18n: Translator,
4238 min_password: i64,
4240}
4241
4242impl SetupTemplate {
4243 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
4246 let mut text = Text::dynamic(source);
4247 for (name, value) in args {
4248 text = text.arg(*name, *value);
4249 }
4250 self.i18n.t(&text)
4251 }
4252
4253 fn asset(&self, key: &str) -> String {
4254 preauth_asset(&self.asset_urls, &self.base, key)
4255 }
4256 fn t(&self, source: &str) -> String {
4257 self.i18n.t(&Text::dynamic(source))
4258 }
4259 fn locale(&self) -> &str {
4260 self.i18n.locale()
4261 }
4262}
4263
4264#[derive(Template)]
4265#[template(path = "preferences.html")]
4266struct PreferencesTemplate {
4267 shell: Shell,
4268 zones: Vec<TzOption>,
4269 effective_tz: String,
4271 default_tz: String,
4273 inherits: bool,
4275 locales: Vec<LocaleOption>,
4276 default_locale: String,
4278 inherits_locale: bool,
4280 sessions: Vec<SessionRow>,
4282 password_changed: Option<String>,
4284 must_change: bool,
4286 min_password: i64,
4288 items: Vec<PreferenceLink>,
4290}
4291
4292struct SessionRow {
4294 id: String,
4295 started: String,
4296 last_seen: String,
4297 expires: String,
4298 device: String,
4301 ip_address: String,
4303 current: bool,
4304}
4305
4306struct TzOption {
4307 name: String,
4308 selected: bool,
4309}
4310
4311struct LocaleOption {
4312 code: String,
4314 name: String,
4316 selected: bool,
4317}
4318
4319#[derive(Clone)]
4321struct CrumbView {
4322 label: String,
4323 href: String,
4324}
4325
4326#[derive(Clone)]
4327struct NavView {
4328 key: String,
4330 label: String,
4331 path: String,
4332 active: bool,
4333 icon: String,
4336}
4337
4338pub(crate) fn render<T: Template>(template: T) -> Response {
4340 match template.render() {
4341 Ok(html) => Html(html).into_response(),
4342 Err(e) => AdminError::from(e).into_response(),
4344 }
4345}
4346
4347pub(crate) fn render_error() -> Response {
4348 error::masked_500()
4349}
4350
4351pub(crate) fn not_found() -> Response {
4352 AdminError::NotFound.into_response()
4353}
4354
4355pub(crate) fn forbidden() -> Response {
4356 AdminError::Forbidden.into_response()
4357}
4358
4359#[cfg(test)]
4360mod tests {
4361 use super::*;
4362
4363 fn write_form() -> form::FormConfig {
4364 form::FormConfig {
4365 enter: Default::default(),
4366 confirm_leave: true,
4367 focus: Default::default(),
4368 entity: "widgets".to_string(),
4369 title: "Widgets".into(),
4370 base_path: "/widgets".to_string(),
4371 id_field: "id".to_string(),
4372 fields: vec![],
4373 persist: None,
4374 timestamps: false,
4375 done_path: None,
4376 }
4377 }
4378
4379 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
4380 Resource {
4381 base_path: "/widgets".to_string(),
4382 nav_label: "Widgets".into(),
4383 list: backend_users_list_config(),
4384 form,
4385 permission: permission.map(str::to_string),
4386 import: None,
4387 palette: None,
4388 relations: Vec::new(),
4389 menu: nav::Placement::default(),
4390 }
4391 }
4392
4393 #[test]
4394 #[should_panic(expected = "must declare a permission")]
4395 fn write_resource_without_permission_aborts_boot() {
4396 let resource = resource_with(Some(write_form()), None);
4399 let _ = mount_resource(
4400 &resource,
4401 &field::builtin_registry(),
4402 &list::builtin_column_registry(),
4403 &std::collections::HashSet::new(),
4404 &persist::PersisterRegistry::new(),
4405 &[],
4406 );
4407 }
4408
4409 #[test]
4411 fn the_access_log_is_read_from_its_descriptor_file() {
4412 let access = builtin_resources()
4413 .into_iter()
4414 .find(|r| r.base_path == "/access-log")
4415 .expect("the access log is registered");
4416 assert_eq!(access.list.entity, "backend_access_log");
4417 assert_eq!(
4418 access.permission.as_deref(),
4419 Some("backend.view_access_log")
4420 );
4421 assert!(access.form.is_none(), "append-only");
4422 assert_eq!(
4423 access
4424 .list
4425 .columns
4426 .iter()
4427 .map(|c| c.field.as_str())
4428 .collect::<Vec<_>>(),
4429 [
4430 "created_at",
4431 "username_attempted",
4432 "event",
4433 "ip_address",
4434 "user_agent"
4435 ]
4436 );
4437 }
4438
4439 #[test]
4442 fn the_audit_log_is_read_from_its_descriptor_file() {
4443 let audit = builtin_resources()
4444 .into_iter()
4445 .find(|r| r.base_path == "/audit-log")
4446 .expect("the audit log is registered");
4447 assert_eq!(audit.list.entity, "backend_audit_log");
4448 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
4449 assert_eq!(
4450 audit
4451 .list
4452 .columns
4453 .iter()
4454 .map(|c| c.field.as_str())
4455 .collect::<Vec<_>>(),
4456 [
4457 "created_at",
4458 "actor_username",
4459 "action",
4460 "target_type",
4461 "target_id",
4462 "target_label"
4463 ],
4464 "in the order the file writes them"
4465 );
4466 assert_eq!(audit.list.columns[0].column_type, "datetime");
4467 assert_eq!(audit.list.per_page, 50);
4468 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
4469 assert!(audit.list.exportable, "evidence may leave the panel");
4470 assert!(audit.form.is_none(), "append-only: no form");
4471 assert!(!audit.list.deletable, "and nothing removes from it");
4472 }
4473
4474 #[test]
4475 #[should_panic(expected = "uses unregistered type `nope`")]
4476 fn unregistered_column_type_aborts_boot() {
4477 let mut resource = resource_with(None, None);
4478 let mut score = list::ListColumn::new("score", "Score");
4479 score.column_type = "nope".into();
4480 resource.list.columns.push(score);
4481 let _ = mount_resource(
4482 &resource,
4483 &field::builtin_registry(),
4484 &list::builtin_column_registry(),
4485 &std::collections::HashSet::new(),
4486 &persist::PersisterRegistry::new(),
4487 &[],
4488 );
4489 }
4490
4491 #[test]
4492 fn read_only_resource_may_omit_permission() {
4493 let resource = resource_with(None, None);
4495 let _ = mount_resource(
4496 &resource,
4497 &field::builtin_registry(),
4498 &list::builtin_column_registry(),
4499 &std::collections::HashSet::new(),
4500 &persist::PersisterRegistry::new(),
4501 &[],
4502 );
4503 }
4504
4505 #[test]
4506 fn shell_localizes_through_the_translator_and_falls_back() {
4507 let shell = Shell::test();
4509 assert_eq!(shell.t("Save"), "Save");
4510 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
4511 }
4512
4513 #[test]
4514 fn shell_tf_interpolates_integer_arguments() {
4515 let shell = Shell::test();
4516 assert_eq!(
4517 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
4518 "Page 2 of 5"
4519 );
4520 }
4521
4522 #[test]
4523 fn display_tz_prefers_a_valid_operator_preference() {
4524 assert_eq!(
4525 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
4526 Tz::Asia__Kolkata
4527 );
4528 }
4529
4530 #[test]
4531 fn display_tz_falls_back_when_unset_or_invalid() {
4532 let default = Tz::Europe__London;
4533 assert_eq!(resolve_display_tz(None, default), default);
4535 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
4537 assert_eq!(resolve_display_tz(Some(""), default), default);
4538 }
4539
4540 #[test]
4541 fn accept_language_orders_by_weight_and_drops_wildcard() {
4542 assert_eq!(
4543 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
4544 vec!["en-US", "en", "kn"]
4545 );
4546 assert_eq!(
4548 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
4549 vec!["kn", "en"]
4550 );
4551 }
4552
4553 #[test]
4554 fn locale_chain_prefers_operator_then_header_then_default() {
4555 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
4557 assert_eq!(
4560 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
4561 vec!["kn", "ta", "hi", "en"]
4562 );
4563 assert_eq!(
4565 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
4566 vec!["kn", "en", "ta"]
4567 );
4568 assert_eq!(
4570 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
4571 vec!["kn", "en"]
4572 );
4573 }
4574
4575 #[test]
4576 fn locale_chain_skips_unserveable_and_defaults_to_en() {
4577 let loaded = ["hi".to_string()];
4579 assert_eq!(
4582 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
4583 vec!["en"]
4584 );
4585 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
4587 assert_eq!(
4589 resolve_locale_chain(Some("hi"), None, "en", &loaded),
4590 vec!["hi", "en"]
4591 );
4592 assert_eq!(
4594 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
4595 vec![laterite_core::PSEUDO_LOCALE, "en"]
4596 );
4597 }
4598
4599 #[test]
4600 fn page_assets_dedupe_preserve_order_and_classify() {
4601 let mut reg = AssetRegistry::new();
4602 reg.insert(
4603 "a.js",
4604 AdminAsset {
4605 mime: "text/javascript; charset=utf-8",
4606 bytes: std::borrow::Cow::Borrowed(b"//"),
4607 },
4608 );
4609 reg.insert(
4610 "b.css",
4611 AdminAsset {
4612 mime: "text/css; charset=utf-8",
4613 bytes: std::borrow::Cow::Borrowed(b"body{}"),
4614 },
4615 );
4616 let urls = asset_urls(®);
4619 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
4620 assert_eq!(assets.len(), 2);
4621 assert_eq!(
4622 assets[0].url,
4623 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
4624 );
4625 assert!(!assets[0].css);
4626 assert_eq!(
4627 assets[1].url,
4628 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
4629 );
4630 assert!(assets[1].css);
4631 assert_ne!(assets[0].url, assets[1].url);
4634 }
4635
4636 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
4640 laterite_core::testing::connect_test(&[]).await
4641 }
4642
4643 #[tokio::test]
4644 async fn builtin_migrations_create_the_admin_tables() {
4645 let (db, _guard) = empty_db().await;
4646 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
4647 .await
4648 .unwrap();
4649 for table in ["backend_users", "settings"] {
4653 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
4654 .fetch_optional(&db.pool)
4655 .await;
4656 assert!(
4657 probe.is_ok(),
4658 "{table} should exist after builtin_migrations"
4659 );
4660 }
4661 }
4662
4663 #[tokio::test]
4664 async fn brand_setting_overrides_config_and_blank_falls_back() {
4665 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
4666 let state = AdminState {
4667 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
4668 db: db.clone(),
4669 nav: Arc::new(nav::NavTree::default()),
4670 settings: Arc::new(Vec::new()),
4671 permissions: Arc::new(builtin_permissions()),
4672 trusted_proxies: Arc::new(Vec::new()),
4673 icons: Arc::new(laterite_core::icons::IconSet::new()),
4674 sprite_url: Arc::from(""),
4675 admin_path: Arc::from("/admin"),
4676 cookie_path: Arc::from("/admin"),
4677 storage_dir: Arc::from("storage"),
4678 signing_key: Arc::new(random_key()),
4679 secure_cookie: false,
4680 origin: Arc::from(""),
4681 timezone: Tz::UTC,
4682 default_locale: Arc::from("en"),
4683 catalogs: Arc::new(CatalogStore::default()),
4684 app_name: "Configured Name".to_string(),
4685 brand_cache: Arc::new(RwLock::new(None)),
4686 field_types: Arc::new(field::builtin_registry()),
4687 column_types: Arc::new(list::builtin_column_registry()),
4688 plugin_defined: Arc::new(laterite_core::Registry::new()),
4689 assets: Arc::new(builtin_assets()),
4690 asset_urls: Arc::new(asset_urls(&builtin_assets())),
4691 pickers: Arc::new(picker::PickerRegistry::new()),
4692 palette: Arc::new(Vec::new()),
4693 overrides: Arc::new(field::NoOverrides),
4694 };
4695
4696 assert_eq!(state.brand().await, "Configured Name");
4698
4699 settings::store::save(
4702 &db,
4703 &settings::BrandSetting {
4704 app_name: "Acme Corp".to_string(),
4705 },
4706 )
4707 .await
4708 .unwrap();
4709 state.invalidate_brand();
4710 assert_eq!(state.brand().await, "Acme Corp");
4711
4712 settings::store::save(
4714 &db,
4715 &settings::BrandSetting {
4716 app_name: " ".to_string(),
4717 },
4718 )
4719 .await
4720 .unwrap();
4721 state.invalidate_brand();
4722 assert_eq!(state.brand().await, "Configured Name");
4723 }
4724
4725 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
4726 settings::SettingsItem {
4727 code: code.to_string(),
4728 label: code.into(),
4729 description: String::new().into(),
4730 category: "General".into(),
4731 hint: None,
4732 confirm_leave: true,
4733 order: 1,
4734 icon: None,
4735 permission: permission.map(str::to_string),
4736 link: None,
4737 fields: Vec::new(),
4738 route: None,
4739 per_operator: false,
4740 }
4741 }
4742
4743 #[test]
4744 fn settings_visibility_respects_permissions() {
4745 let items = vec![
4746 settings_item("public", None),
4747 settings_item("gated", Some("backend.manage_users")),
4748 ];
4749
4750 let none = PermissionSet::new(false, Vec::<String>::new());
4752 let codes: Vec<String> = visible_settings(&items, &none)
4753 .into_iter()
4754 .map(|i| i.code)
4755 .collect();
4756 assert_eq!(codes, ["public"]);
4757
4758 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
4760 assert_eq!(visible_settings(&items, &granted).len(), 2);
4761
4762 let superuser = PermissionSet::new(true, Vec::<String>::new());
4764 assert_eq!(visible_settings(&items, &superuser).len(), 2);
4765 }
4766
4767 #[test]
4768 fn context_sidebar_follows_settings_links() {
4769 let items = builtin_settings();
4770 let superuser = PermissionSet::new(true, Vec::<String>::new());
4771 let tr = Translator::new("en");
4772 let tree = nav::NavTree::default();
4773 let sidebar = |path: &str| {
4774 resolve_nav_context(
4775 &tree,
4776 &items,
4777 "/admin",
4778 &superuser,
4779 None,
4780 path,
4781 &tr,
4782 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
4783 )
4784 .1
4785 };
4786 let active_path = |path: &str| -> Option<String> {
4787 sidebar(path)
4788 .into_iter()
4789 .flat_map(|g| g.items)
4790 .find(|i| i.active)
4791 .map(|i| i.path)
4792 };
4793
4794 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
4796 assert_eq!(
4797 active_path("/admin/roles/42/edit").as_deref(),
4798 Some("/admin/roles")
4799 );
4800 assert!(!sidebar("/admin/settings").is_empty());
4802 assert_eq!(active_path("/admin/settings"), None);
4803 assert_eq!(
4808 active_path("/admin/settings/branding").as_deref(),
4809 Some("/admin/settings/branding")
4810 );
4811 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
4814 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
4815 assert!(sidebar("/admin").is_empty());
4817 }
4818}
4819
4820#[cfg(test)]
4821mod session_cookie_tests {
4822 use super::*;
4823
4824 #[test]
4828 fn the_session_cookie_never_outlives_the_browser() {
4829 let cookie = session_cookie("tok".into(), "/admin", false);
4830 assert_eq!(cookie.max_age(), None);
4831 assert!(cookie.http_only().unwrap_or(false));
4832 assert_eq!(cookie.path(), Some("/admin"));
4833 }
4834
4835 #[test]
4838 fn the_remember_cookie_carries_its_own_lifetime() {
4839 let credential = laterite_auth::RememberCredential {
4840 cookie: "sel:ver".into(),
4841 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
4842 };
4843 let cookie = remember_cookie(credential, "/admin", true);
4844 let age = cookie.max_age().expect("a remember cookie must persist");
4845 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
4847 assert!(cookie.http_only().unwrap_or(false));
4848 assert!(cookie.secure().unwrap_or(false));
4849 }
4850
4851 #[test]
4853 fn the_removal_matches_the_cookie_it_clears() {
4854 let removal = remember_removal("/admin");
4855 assert_eq!(removal.name(), REMEMBER_COOKIE);
4856 assert_eq!(removal.path(), Some("/admin"));
4857 }
4858}