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 nav;
34pub(crate) mod palette;
35pub mod persist;
36pub mod picker;
37pub mod plugins;
38pub mod relation;
39pub(crate) mod requestlog;
40mod roles;
41mod routemap;
42pub mod routes;
43mod session;
44pub mod settings;
45mod sql;
46pub mod ui;
47mod upload;
48mod users;
49
50pub use axum;
58
59pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
60pub use error::AdminError;
61pub use session::{FlashLevel, SessionHandle};
62pub use upload::VerifiedUpload;
63
64use std::collections::HashMap;
65use std::sync::{Arc, RwLock};
66
67use askama::Template;
68use axum::body::Body;
69use axum::extract::{Path, Query, Request, State};
70use axum::http::{header, HeaderMap};
71use axum::middleware::{self, Next};
72use axum::response::{Html, IntoResponse, Redirect, Response};
73use axum::routing::{any, get, post};
74use axum::{Extension, Form, Router};
75use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
76use chrono_tz::{Tz, TZ_VARIANTS};
77use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
78use laterite_core::{t, CatalogStore, Db, Text, Translator};
79use serde::{Deserialize, Serialize};
80
81pub trait AdminRegistry {
87 fn add_resource(&mut self, resource: Resource);
89 fn add_permission(&mut self, permission: Permission);
91 fn add_settings(&mut self, item: settings::SettingsItem);
93 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
95 fn add_persister(&mut self, persister: persist::PersisterReg);
97 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
99 fn add_screen(&mut self, screen: routes::ScreenReg);
102 fn add_public_route(&mut self, route: routes::PublicRouteReg);
105 fn add_nav(&mut self, item: nav::NavItem);
107 fn add_resource_hook(&mut self, hook: ResourceHook);
109}
110
111impl AdminRegistry for laterite_core::Registry {
112 fn add_resource(&mut self, resource: Resource) {
113 self.add(resource);
114 }
115 fn add_permission(&mut self, permission: Permission) {
116 self.add(permission);
117 }
118 fn add_settings(&mut self, item: settings::SettingsItem) {
119 self.add(item);
120 }
121 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
122 self.add(source);
123 }
124 fn add_screen(&mut self, screen: routes::ScreenReg) {
125 self.add(screen);
126 }
127 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
128 self.add(route);
129 }
130 fn add_nav(&mut self, item: nav::NavItem) {
131 self.add(item);
132 }
133 fn add_resource_hook(&mut self, hook: ResourceHook) {
134 self.add(hook);
135 }
136 fn add_persister(&mut self, persister: persist::PersisterReg) {
137 self.add(persister);
138 }
139 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
140 self.add(field_type);
141 }
142}
143
144#[derive(Clone)]
151pub struct ResourceHook {
152 pub target: String,
153 apply: Arc<dyn Fn(Resource) -> Resource + Send + Sync>,
154}
155
156impl ResourceHook {
157 pub fn new(
158 target: impl Into<String>,
159 apply: impl Fn(Resource) -> Resource + Send + Sync + 'static,
160 ) -> Self {
161 Self {
162 target: target.into(),
163 apply: Arc::new(apply),
164 }
165 }
166
167 pub fn apply(&self, resource: Resource) -> Resource {
169 (self.apply)(resource)
170 }
171}
172
173impl std::fmt::Debug for ResourceHook {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 write!(f, "ResourceHook({})", self.target)
176 }
177}
178
179pub(crate) const SESSION_COOKIE: &str = "laterite_session";
180const REMEMBER_COOKIE: &str = "laterite_remember";
184
185pub(crate) const ICON_SPRITE: &str = "icons.svg";
188
189#[derive(Clone)]
191pub(crate) struct AdminState {
192 auth: AuthService,
193 db: Db,
194 nav: Arc<nav::NavTree>,
195 settings: Arc<Vec<settings::SettingsItem>>,
196 permissions: Arc<Vec<Permission>>,
197 admin_path: Arc<str>,
201 cookie_path: Arc<str>,
205 secure_cookie: bool,
206 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
208 icons: Arc<laterite_core::icons::IconSet>,
210 sprite_url: Arc<str>,
212 origin: Arc<str>,
214 timezone: Tz,
215 default_locale: Arc<str>,
219 catalogs: Arc<CatalogStore>,
222 app_name: String,
225 brand_cache: Arc<RwLock<Option<String>>>,
229 asset_urls: Arc<AssetUrls>,
231 field_types: Arc<field::FieldRegistry>,
234 column_types: Arc<list::ColumnRegistry>,
237 plugin_defined: Arc<laterite_core::Registry>,
240 assets: Arc<AssetRegistry>,
242 pickers: Arc<picker::PickerRegistry>,
245 palette: Arc<Vec<palette::RecordSource>>,
248 overrides: Arc<dyn field::OverrideResolver>,
252}
253
254impl AdminState {
255 #[cfg(test)]
256 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
257 Self {
258 auth,
259 db,
260 nav: Arc::new(nav::NavTree::default()),
261 settings: Arc::new(Vec::new()),
262 permissions: Arc::new(builtin_permissions()),
263 trusted_proxies: Arc::new(Vec::new()),
264 icons: Arc::new(laterite_core::icons::IconSet::new()),
265 sprite_url: Arc::from(""),
266 admin_path: Arc::from("/admin"),
267 cookie_path: Arc::from("/admin"),
268 secure_cookie: false,
269 origin: Arc::from(""),
270 timezone: Tz::UTC,
271 default_locale: Arc::from("en"),
272 catalogs: Arc::new(CatalogStore::default()),
273 app_name: "Laterite".to_string(),
274 brand_cache: Arc::new(RwLock::new(None)),
275 field_types: Arc::new(field::builtin_registry()),
276 column_types: Arc::new(list::builtin_column_registry()),
277 plugin_defined: Arc::new(laterite_core::Registry::new()),
278 assets: Arc::new(builtin_assets()),
279 asset_urls: Arc::new(asset_urls(&builtin_assets())),
280 pickers: Arc::new(picker::PickerRegistry::new()),
281 palette: Arc::new(Vec::new()),
282 overrides: Arc::new(field::NoOverrides),
283 }
284 }
285
286 async fn brand(&self) -> String {
290 {
291 let cached = self.brand_cache.read().unwrap().clone();
292 if let Some(name) = cached {
293 return name;
294 }
295 }
296 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
297 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
298 _ => self.app_name.clone(),
299 };
300 *self.brand_cache.write().unwrap() = Some(resolved.clone());
301 resolved
302 }
303
304 fn invalidate_brand(&self) {
306 *self.brand_cache.write().unwrap() = None;
307 }
308}
309
310#[derive(Clone)]
316#[non_exhaustive]
317pub struct AdminConfig {
318 pub secure_cookie: bool,
321 pub trusted_proxies: Vec<String>,
325 pub timezone: String,
329 pub locale: String,
333 pub app_name: String,
337 pub path: String,
341 pub origin: String,
346 pub storage_dir: String,
351}
352
353impl Default for AdminConfig {
354 fn default() -> Self {
355 Self {
356 secure_cookie: false,
357 trusted_proxies: Vec::new(),
358 timezone: "UTC".to_string(),
359 locale: "en".to_string(),
360 app_name: "Laterite".to_string(),
361 path: "/admin".to_string(),
362 origin: String::new(),
363 storage_dir: "storage".to_string(),
364 }
365 }
366}
367
368#[derive(Clone)]
377pub struct Shell {
378 pub(crate) base: String,
382 brand: String,
386 nav: Vec<NavView>,
387 full_name: String,
388 initial: String,
389 tz: Tz,
393 i18n: Translator,
397 sidebar: Vec<settings::CategoryView>,
401 crumbs: Vec<CrumbView>,
404 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
406 pub(crate) sprite_url: Arc<str>,
409 site_url: String,
413 asset_urls: Arc<AssetUrls>,
417 pub(crate) csrf_token: String,
421 pub(crate) flash: Vec<FlashLine>,
425 pub(crate) assets: Vec<PageAsset>,
429}
430
431#[derive(Template)]
433#[template(path = "page.html")]
434struct PageTemplate {
435 title: String,
436 body: html::Markup,
437 shell: Shell,
438}
439
440#[derive(Clone)]
442pub(crate) struct PageAsset {
443 pub(crate) url: String,
444 pub(crate) css: bool,
445}
446
447#[derive(Clone)]
450pub(crate) struct FlashLine {
451 pub(crate) level: session::FlashLevel,
452 pub(crate) text: String,
453 pub(crate) sticky: bool,
455}
456
457impl Shell {
458 #[allow(clippy::too_many_arguments)]
459 fn new(
460 base: &str,
461 brand: String,
462 nav: &[nav::NavNode],
463 user: &AuthenticatedUser,
464 default_tz: Tz,
465 sidebar: Vec<settings::CategoryView>,
466 active_nav: Option<&str>,
467 csrf_token: String,
468 flash: Vec<session::Flash>,
469 i18n: Translator,
470 asset_urls: Arc<AssetUrls>,
471 site_url: String,
472 icon_set: Arc<laterite_core::icons::IconSet>,
473 sprite_url: Arc<str>,
474 ) -> Self {
475 let full_name = user.user.full_name();
476 let initial = full_name
477 .chars()
478 .next()
479 .map(|c| c.to_uppercase().to_string())
480 .unwrap_or_else(|| "?".to_string());
481 let flash = flash
484 .into_iter()
485 .map(|f| FlashLine {
486 sticky: f.sticky,
487 level: f.level,
488 text: i18n.t(&f.text),
489 })
490 .collect();
491 let nav: Vec<NavView> = nav
492 .iter()
493 .map(|n| NavView {
494 key: n.item.key.clone(),
495 label: i18n.t(&n.item.label),
496 path: nav::tab_path(n).unwrap_or_default().to_string(),
497 active: active_nav == Some(n.item.key.as_str()),
498 icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
499 })
500 .collect();
501 let mut crumbs: Vec<CrumbView> = nav
502 .iter()
503 .filter(|n: &&NavView| n.active)
504 .map(|n| CrumbView {
505 label: n.label.clone(),
506 href: n.path.clone(),
507 })
508 .collect();
509 if let Some(entry) = sidebar
510 .iter()
511 .flat_map(|g| g.items.iter())
512 .find(|i| i.active)
513 {
514 crumbs.push(CrumbView {
515 label: entry.label.clone(),
516 href: entry.path.clone(),
517 });
518 }
519 Shell {
520 asset_urls,
521 icon_set,
522 sprite_url,
523 site_url,
524 base: base.to_string(),
525 brand,
526 nav,
527 crumbs,
528 full_name,
529 initial,
530 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
531 i18n,
532 sidebar,
533 csrf_token,
534 flash,
535 assets: Vec::new(),
536 }
537 }
538
539 pub(crate) fn site_url(&self) -> &str {
541 &self.site_url
542 }
543
544 pub(crate) fn sidebar_anchored(&self) -> bool {
548 self.nav.iter().any(|n| n.active)
549 }
550
551 pub(crate) fn icon(&self, name: &str) -> String {
552 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
553 }
554
555 pub(crate) fn asset(&self, key: &str) -> String {
560 match self.asset_urls.get(key) {
561 Some(path) => format!("{}/assets/{path}", self.base),
562 None => {
563 debug_assert!(false, "asset key `{key}` is not registered");
564 format!("{}/assets/{key}", self.base)
565 }
566 }
567 }
568
569 pub fn t(&self, source: &str) -> String {
572 self.i18n.t(&Text::dynamic(source))
573 }
574
575 pub fn tt(&self, text: &Text) -> String {
578 self.i18n.t(text)
579 }
580
581 pub fn i18n(&self) -> &Translator {
584 &self.i18n
585 }
586
587 pub fn locale(&self) -> &str {
589 self.i18n.locale()
590 }
591
592 pub fn base(&self) -> &str {
594 &self.base
595 }
596
597 pub fn csrf_token(&self) -> &str {
601 &self.csrf_token
602 }
603
604 pub fn datetime(&self, utc: &str) -> String {
607 match chrono::DateTime::parse_from_rfc3339(utc) {
608 Ok(at) => at
609 .with_timezone(&self.tz)
610 .format("%Y-%m-%d %H:%M")
611 .to_string(),
612 Err(_) => utc.to_string(),
613 }
614 }
615
616 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
620 render(PageTemplate {
621 title: self.tt(title),
622 body,
623 shell: self.clone(),
624 })
625 }
626
627 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
631 let mut text = Text::dynamic(source);
632 for (name, value) in args {
633 text = text.arg(*name, *value);
634 }
635 self.i18n.t(&text)
636 }
637
638 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
641 let mut text = Text::dynamic(source);
642 for (name, value) in args {
643 text = text.arg(*name, *value);
644 }
645 self.i18n.t(&text)
646 }
647
648 #[cfg(test)]
649 pub(crate) fn test() -> Self {
650 Shell {
651 asset_urls: Arc::new(asset_urls(&builtin_assets())),
652 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
653 sprite_url: Arc::from("/admin/assets/icons.svg"),
654 site_url: "http://localhost".to_string(),
655 base: "/admin".to_string(),
656 brand: "Laterite".to_string(),
657 nav: Vec::new(),
658 full_name: "Test Operator".to_string(),
659 initial: "T".to_string(),
660 tz: Tz::UTC,
661 i18n: Translator::new("en"),
662 sidebar: Vec::new(),
663 crumbs: Vec::new(),
664 csrf_token: "test-csrf-token".to_string(),
665 flash: Vec::new(),
666 assets: Vec::new(),
667 }
668 }
669}
670
671fn settings_context(
678 visible: &[settings::SettingsItem],
679 admin_path: &str,
680 path: &str,
681) -> (bool, Option<String>) {
682 if path == format!("{admin_path}/settings") {
683 return (true, None);
684 }
685 let own = visible
691 .iter()
692 .filter(|i| i.link.is_none())
693 .find(|i| path == format!("{admin_path}{}", i.route()))
694 .map(|i| i.code.clone());
695 if own.is_some() {
696 return (true, own);
697 }
698 let linked = visible
699 .iter()
700 .filter_map(|i| {
701 i.link
702 .as_deref()
703 .map(|link| (format!("{admin_path}{link}"), &i.code))
704 })
705 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
706 .max_by_key(|(link, _)| link.len())
707 .map(|(_, code)| code.clone());
708 (linked.is_some(), linked)
709}
710
711#[allow(clippy::too_many_arguments)]
717fn resolve_nav_context(
718 nav: &nav::NavTree,
719 items: &[settings::SettingsItem],
720 admin_path: &str,
721 perms: &PermissionSet,
722 own_order: Option<&str>,
723 path: &str,
724 tr: &Translator,
725 icons: icons::Icons<'_>,
726) -> (
727 Vec<nav::NavNode>,
728 Vec<settings::CategoryView>,
729 Option<String>,
730) {
731 let mut visible_nav = nav.visible(&|code| perms.allows(code));
732 if let Some(order) = own_order {
733 nav::apply_order(&mut visible_nav, order);
734 }
735 let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
737 .into_iter()
738 .filter(|i| !i.per_operator)
739 .collect();
740 let (in_context, active) = settings_context(&visible, admin_path, path);
741 if in_context {
742 let sidebar = settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons);
743 return (visible_nav, sidebar, Some("settings".to_string()));
744 }
745 let active_top = nav::active_top(&visible_nav, admin_path, path);
746 let sidebar = active_top
747 .map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
748 .unwrap_or_default();
749 let active_key = active_top.map(|node| node.item.key.clone());
750 (visible_nav, sidebar, active_key)
751}
752
753fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
757 preference
758 .and_then(|name| name.parse::<Tz>().ok())
759 .unwrap_or(default_tz)
760}
761
762fn zone_offered(name: &str) -> bool {
764 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
765}
766
767fn base_lang(tag: &str) -> String {
770 tag.split(['-', '_'])
771 .next()
772 .unwrap_or(tag)
773 .to_ascii_lowercase()
774}
775
776fn is_serveable(base: &str, serveable: &[String]) -> bool {
782 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
783}
784
785fn default_locale(configured: &str, serveable: &[String]) -> String {
788 let base = base_lang(configured);
789 if is_serveable(&base, serveable) {
790 base
791 } else {
792 "en".to_string()
793 }
794}
795
796fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
800 let mut out = vec!["en".to_string()];
801 out.extend(catalogs.locales());
802 out
803}
804
805fn locale_name(code: &str) -> String {
810 if code == "en" {
811 "English".to_string()
812 } else {
813 code.to_string()
814 }
815}
816
817fn parse_accept_language(header: &str) -> Vec<String> {
820 let mut items: Vec<(f32, String)> = header
821 .split(',')
822 .filter_map(|part| {
823 let mut bits = part.split(';');
824 let tag = bits.next()?.trim();
825 if tag.is_empty() || tag == "*" {
826 return None;
827 }
828 let q = bits
829 .find_map(|p| p.trim().strip_prefix("q="))
830 .and_then(|q| q.parse::<f32>().ok())
831 .unwrap_or(1.0);
832 Some((q, tag.to_string()))
833 })
834 .collect();
835 items.sort_by(|a, b| b.0.total_cmp(&a.0));
837 items.into_iter().map(|(_, tag)| tag).collect()
838}
839
840fn resolve_locale_chain(
846 preference: Option<&str>,
847 accept_language: Option<&str>,
848 default: &str,
849 serveable: &[String],
850) -> Vec<String> {
851 let mut chain: Vec<String> = Vec::new();
852 let consider = |tag: &str, chain: &mut Vec<String>| {
853 let base = base_lang(tag);
854 if is_serveable(&base, serveable) && !chain.contains(&base) {
855 chain.push(base);
856 }
857 };
858 if let Some(pref) = preference {
859 consider(pref, &mut chain);
860 }
861 if let Some(header) = accept_language {
862 for tag in parse_accept_language(header) {
863 consider(&tag, &mut chain);
864 }
865 }
866 consider(default, &mut chain);
867 consider("en", &mut chain);
870 chain
871}
872
873fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
877 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
878 let serveable = state.catalogs.locales();
879 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
880 Translator::with_chain(chain, state.catalogs.clone())
881}
882
883#[derive(Serialize)]
891#[non_exhaustive]
892pub struct Resource {
893 pub base_path: String,
899 pub nav_label: Text,
901 pub list: list::ListConfig,
902 pub form: Option<form::FormConfig>,
903 pub permission: Option<String>,
910 pub import: Option<ImportConfig>,
913 pub palette: Option<String>,
916 pub relations: Vec<relation::RelationConfig>,
919 pub menu: nav::Placement,
922}
923
924#[derive(Clone, Debug, Default, Serialize, Deserialize)]
927#[non_exhaustive]
928pub struct ImportConfig {
929 #[serde(default)]
932 pub permission: Option<String>,
933}
934
935impl ImportConfig {
936 pub fn new() -> Self {
937 Self::default()
938 }
939
940 pub fn permission(mut self, code: impl Into<String>) -> Self {
943 self.permission = Some(code.into());
944 self
945 }
946}
947
948#[derive(Clone, Serialize)]
953#[non_exhaustive]
954pub struct Permission {
955 pub code: String,
956 pub label: Text,
958 pub group: Text,
959 pub default_roles: Vec<String>,
962 pub renamed_from: Vec<String>,
965}
966
967pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
968
969impl Permission {
970 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
973 Self {
974 code: code.into(),
975 label: label.into(),
976 group: group.into(),
977 default_roles: Vec::new(),
978 renamed_from: Vec::new(),
979 }
980 }
981
982 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
988 where
989 I: IntoIterator<Item = S>,
990 S: Into<String>,
991 {
992 self.renamed_from = codes.into_iter().map(Into::into).collect();
993 self
994 }
995
996 pub fn roles<I, S>(mut self, roles: I) -> Self
999 where
1000 I: IntoIterator<Item = S>,
1001 S: Into<String>,
1002 {
1003 self.default_roles = roles.into_iter().map(Into::into).collect();
1004 self
1005 }
1006}
1007
1008pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
1014 let permissions: Vec<Permission> = builtin_permissions()
1015 .into_iter()
1016 .chain(contributed.iter().cloned())
1017 .collect();
1018 let mut out: Vec<(String, String)> = Vec::new();
1019 for permission in &permissions {
1020 for old in &permission.renamed_from {
1021 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
1022 panic!(
1023 "permissions `{taken}` and `{}` both claim the old code `{old}`",
1024 permission.code
1025 );
1026 }
1027 if permissions.iter().any(|p| &p.code == old) {
1028 panic!(
1029 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
1030 permission.code
1031 );
1032 }
1033 out.push((old.clone(), permission.code.clone()));
1034 }
1035 }
1036 out
1037}
1038
1039pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
1045 let permissions: Vec<Permission> = builtin_permissions()
1048 .into_iter()
1049 .chain(contributed.iter().cloned())
1050 .collect();
1051 let held = |role: &str| -> Vec<String> {
1052 permissions
1053 .iter()
1054 .filter(|p| {
1055 if p.default_roles.is_empty() {
1056 role == ROLE_ADMIN
1057 } else {
1058 p.default_roles.iter().any(|r| r == role)
1059 }
1060 })
1061 .map(|p| p.code.clone())
1062 .collect()
1063 };
1064 vec![
1065 laterite_auth::store::SystemRole {
1066 code: ROLE_ADMIN,
1067 name: "Administrator",
1068 description: "Administers the panel itself: operators, roles and settings.",
1069 permissions: held(ROLE_ADMIN),
1070 },
1071 laterite_auth::store::SystemRole {
1072 code: ROLE_EDITOR,
1073 name: "Editor",
1074 description: "Works with the content the application declares.",
1075 permissions: held(ROLE_EDITOR),
1076 },
1077 ]
1078}
1079
1080impl Resource {
1081 pub fn new(
1086 base_path: impl Into<String>,
1087 nav_label: impl Into<Text>,
1088 list: list::ListConfig,
1089 ) -> Self {
1090 Self {
1091 base_path: base_path.into(),
1092 nav_label: nav_label.into(),
1093 list,
1094 form: None,
1095 permission: None,
1096 import: None,
1097 palette: None,
1098 relations: Vec::new(),
1099 menu: nav::Placement::default(),
1100 }
1101 }
1102
1103 pub fn palette(mut self, source: impl Into<String>) -> Self {
1106 self.palette = Some(source.into());
1107 self
1108 }
1109
1110 pub fn menu(mut self, placement: nav::Placement) -> Self {
1112 self.menu = placement;
1113 self
1114 }
1115
1116 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1118 self.relations.push(relation);
1119 self
1120 }
1121
1122 pub fn import(mut self, import: ImportConfig) -> Self {
1124 self.import = Some(import);
1125 self
1126 }
1127
1128 pub fn form(mut self, form: form::FormConfig) -> Self {
1131 self.form = Some(form);
1132 self
1133 }
1134
1135 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1137 self.permission = Some(permission.into());
1138 self
1139 }
1140}
1141
1142struct SystemRoleGuard;
1148
1149#[laterite_core::strata::async_trait]
1150impl laterite_core::ModelListener for SystemRoleGuard {
1151 async fn before_delete(
1152 &self,
1153 cx: &mut laterite_core::SaveCx<'_>,
1154 rec: &laterite_core::Record,
1155 ) -> Result<(), Text> {
1156 let Some(id) = rec.id() else {
1157 return Ok(());
1158 };
1159 let backend = cx.backend();
1161 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1162 Ok(true) => Err(t!(
1163 "The framework maintains this role; it cannot be deleted."
1164 )),
1165 Err(_) => Err(t!(
1167 "Could not check whether this role is maintained by the framework."
1168 )),
1169 Ok(false) => Ok(()),
1170 }
1171 }
1172}
1173
1174fn builtin_permissions() -> Vec<Permission> {
1177 vec![
1180 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1181 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1182 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1183 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1184 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1185 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1186 Permission::new(
1187 "backend.view_request_log",
1188 "View the request log",
1189 "Backend",
1190 ),
1191 ]
1192}
1193
1194pub fn descriptor_sources() -> Vec<String> {
1200 let mut out = Vec::new();
1201 out.extend(laterite_core::collect_sources(&builtin_resources()));
1202 out.extend(laterite_core::collect_sources(&builtin_settings()));
1203 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1204 out.sort();
1205 out.dedup();
1206 out
1207}
1208
1209pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1224 builtin_modules().iter().map(|m| m.migrations()).collect()
1225}
1226
1227pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1231 vec![
1232 Box::new(laterite_auth::AuthModule),
1233 Box::new(settings::SettingsModule),
1234 Box::new(requestlog::RequestLogModule),
1235 Box::new(plugins::PluginsModule),
1236 Box::new(laterite_media::MediaModule),
1237 Box::new(import::ImportModule),
1238 ]
1239}
1240
1241#[derive(Default)]
1265pub struct Contributions {
1266 pub resources: Vec<Resource>,
1268 pub settings: Vec<settings::SettingsItem>,
1270 pub permissions: Vec<Permission>,
1272 pub picker_sources: Vec<picker::PickerSourceReg>,
1274 pub persisters: Vec<persist::PersisterReg>,
1276 pub listeners: Vec<laterite_core::ModelListenerReg>,
1278 pub field_types: Vec<field::FieldTypeReg>,
1282 pub column_types: Vec<list::ColumnTypeReg>,
1283 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1286 pub screens: Vec<routes::ScreenReg>,
1288 pub public_routes: Vec<routes::PublicRouteReg>,
1290 pub nav: Vec<nav::NavItem>,
1293 pub resource_hooks: Vec<ResourceHook>,
1296 pub plugin_defined: Arc<laterite_core::Registry>,
1301}
1302
1303pub fn router(
1304 auth: AuthService,
1305 db: Db,
1306 contributions: Contributions,
1307 config: AdminConfig,
1308 catalogs: Arc<CatalogStore>,
1309) -> Router {
1310 let Contributions {
1311 resources: app_resources,
1312 settings: app_settings,
1313 permissions: app_permissions,
1314 picker_sources: app_picker_sources,
1315 persisters: app_persisters,
1316 listeners: app_listeners,
1317 field_types: app_field_types,
1318 column_types: app_column_types,
1319 icons: app_icons,
1320 screens: app_screens,
1321 public_routes: app_public,
1322 nav: app_nav,
1323 resource_hooks,
1324 plugin_defined,
1325 } = contributions;
1326 let admin_path = normalize_path(&config.path);
1327
1328 let mut resources = builtin_resources();
1329 let mut app_resources = app_resources;
1330 for hook in resource_hooks {
1333 let found = resources
1334 .iter_mut()
1335 .chain(app_resources.iter_mut())
1336 .find(|r| r.base_path == hook.target);
1337 match found {
1338 Some(resource) => {
1339 let changed = hook.apply(std::mem::replace(
1340 resource,
1341 Resource::new("", "", list::ListConfig::default()),
1342 ));
1343 *resource = changed;
1344 }
1345 None => panic!(
1346 "a resource hook targets `{}`, which no module declared",
1347 hook.target
1348 ),
1349 }
1350 }
1351 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1355 prefix_resource(&admin_path, resource);
1356 }
1357 for resource in app_resources.iter_mut() {
1361 if let Some(import) = &resource.import {
1362 let permission = import
1363 .permission
1364 .clone()
1365 .or_else(|| resource.permission.clone())
1366 .unwrap_or_else(|| {
1367 panic!(
1368 "admin resource `{}` declares `import` but no permission; importing \
1369 writes records, so it must be gated",
1370 resource.base_path
1371 )
1372 });
1373 resource.list.import = Some(routes::feature_link(
1374 &resource.base_path,
1375 "import",
1376 &permission,
1377 ));
1378 }
1379 }
1380
1381 let mut settings = builtin_settings();
1382 settings.extend(app_settings);
1383 let mut app_listeners = app_listeners;
1386 app_listeners.insert(
1387 0,
1388 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1389 );
1390
1391 let mut permissions = builtin_permissions();
1392 permissions.extend(app_permissions);
1393 let permission_codes: std::collections::HashSet<String> =
1395 permissions.iter().map(|p| p.code.clone()).collect();
1396
1397 let mut nav_items = vec![
1403 nav::NavItem::new("dashboard", "Dashboard", "")
1404 .icon("layout-dashboard")
1405 .order(i32::MIN),
1406 nav::NavItem::new("settings", "Settings", "/settings")
1407 .icon("settings")
1408 .order(i32::MAX),
1409 ];
1410 nav_items.extend(app_nav);
1411 for resource in &app_resources {
1412 if resource.menu.hidden {
1413 continue;
1414 }
1415 let unprefixed = resource
1416 .base_path
1417 .strip_prefix(admin_path.as_str())
1418 .unwrap_or(&resource.base_path)
1419 .to_string();
1420 let mut item = nav::NavItem::new(
1421 format!("resource:{unprefixed}"),
1422 resource.nav_label.clone(),
1423 unprefixed,
1424 )
1425 .order(resource.menu.order);
1426 item.icon = resource.menu.icon.clone();
1427 item.permission = resource.permission.clone();
1428 item.under = resource.menu.under.clone();
1429 item.after = resource.menu.after.clone();
1430 item.before = resource.menu.before.clone();
1431 nav_items.push(item);
1432 }
1433 for reg in &app_screens {
1434 let Some(label) = ®.nav_label else {
1435 continue;
1436 };
1437 if reg.menu.hidden {
1438 continue;
1439 }
1440 let mut item = nav::NavItem::new(
1441 format!("screen:{}", reg.base_path),
1442 label.clone(),
1443 reg.base_path.clone(),
1444 )
1445 .permission(reg.permission.clone())
1446 .order(reg.menu.order);
1447 item.icon = reg.menu.icon.clone();
1448 item.under = reg.menu.under.clone();
1449 item.after = reg.menu.after.clone();
1450 item.before = reg.menu.before.clone();
1451 nav_items.push(item);
1452 }
1453 let nav =
1454 nav::NavTree::build(nav_items, &admin_path).unwrap_or_else(|e| panic!("admin menu: {e}"));
1455 let palette_sources: Vec<palette::RecordSource> = app_resources
1458 .iter()
1459 .filter_map(|r| {
1460 r.palette.as_ref().map(|source| palette::RecordSource {
1461 label: r.nav_label.clone(),
1462 source: source.clone(),
1463 base_path: r.base_path.clone(),
1464 permission: r.permission.clone(),
1465 })
1466 })
1467 .collect();
1468 resources.extend(app_resources);
1469
1470 let app_name = if config.app_name.trim().is_empty() {
1471 "Laterite".to_string()
1472 } else {
1473 config.app_name.clone()
1474 };
1475
1476 let mut pickers = picker::PickerRegistry::new();
1479 for reg in app_picker_sources {
1480 let name = reg.name.clone();
1481 if !field::is_name(&name, true) {
1482 panic!("invalid picker source name `{name}`");
1483 }
1484 if pickers.insert(name.clone(), reg).is_some() {
1485 panic!("duplicate picker source `{name}`");
1486 }
1487 }
1488
1489 for source in &palette_sources {
1493 if !pickers.contains_key(&source.source) {
1494 panic!(
1495 "admin resource `{}` names palette source `{}`, which no module registered",
1496 source.base_path, source.source
1497 );
1498 }
1499 }
1500 let pickers = Arc::new(pickers);
1501 let mut field_types = field::builtin_registry();
1502 field_types.insert(
1503 "reference".to_string(),
1504 Arc::new(field::RefPickerField::new(pickers.clone())),
1505 );
1506
1507 let mut persisters = persist::PersisterRegistry::new();
1510 for reg in app_persisters {
1511 let name = reg.name.clone();
1512 if !field::is_name(&name, true) {
1513 panic!("invalid persister name `{name}`");
1514 }
1515 if persisters.insert(name.clone(), reg.persister).is_some() {
1516 panic!("duplicate persister `{name}`");
1517 }
1518 }
1519
1520 for reg in app_field_types {
1526 let name = reg.name().to_string();
1527 if !field::is_name(&name, true) {
1528 panic!("invalid field type name `{name}`");
1529 }
1530 if field_types.insert(name.clone(), reg.field_type).is_some() {
1531 panic!("duplicate field type `{name}`");
1532 }
1533 }
1534
1535 let mut icon_set = laterite_core::icons::IconSet::new();
1539 for (owner, reg) in app_icons {
1540 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1541 panic!("module `{owner}` contributed an invalid icon: {e}");
1542 }
1543 }
1544
1545 let mut assets = builtin_assets();
1548 assets.insert(
1549 ICON_SPRITE,
1550 AdminAsset {
1551 mime: "image/svg+xml",
1552 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1553 },
1554 );
1555
1556 for item in &settings {
1560 icons::require(
1561 &icon_set,
1562 &format!("settings item `{}`", item.code),
1563 item.icon.as_deref(),
1564 );
1565 }
1566 for node in nav
1567 .top
1568 .iter()
1569 .flat_map(|n| std::iter::once(n).chain(n.children.iter()))
1570 {
1571 icons::require(
1572 &icon_set,
1573 &format!("menu entry `{}`", node.item.key),
1574 node.item.icon.as_deref(),
1575 );
1576 }
1577
1578 let mut column_types = list::builtin_column_registry();
1582 for reg in app_column_types {
1583 let name = reg.name().to_string();
1584 if !field::is_name(&name, true) {
1585 panic!("invalid column type name `{name}`");
1586 }
1587 if column_types.insert(name.clone(), reg.column_type).is_some() {
1588 panic!("duplicate column type `{name}`");
1589 }
1590 }
1591
1592 let settings = Arc::new(settings);
1593 let asset_urls_arc = Arc::new(asset_urls(&assets));
1595 let sprite_url: Arc<str> = Arc::from(
1596 asset_urls_arc
1597 .get(ICON_SPRITE)
1598 .map(|p| format!("{admin_path}/assets/{p}"))
1599 .unwrap_or_default()
1600 .as_str(),
1601 );
1602 let icons_arc = Arc::new(icon_set);
1603 let state = AdminState {
1604 auth,
1605 db,
1606 nav: Arc::new(nav),
1607 settings: settings.clone(),
1608 permissions: Arc::new(permissions),
1609 admin_path: Arc::from(admin_path.as_str()),
1610 secure_cookie: config.secure_cookie,
1611 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1612 icons: icons_arc.clone(),
1613 sprite_url: sprite_url.clone(),
1614 origin: Arc::from(config.origin.trim_end_matches('/')),
1615 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1616 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1617 catalogs,
1618 app_name,
1619 brand_cache: Arc::new(RwLock::new(None)),
1620 field_types: Arc::new(field_types),
1621 column_types: Arc::new(column_types),
1622 plugin_defined,
1623 cookie_path: if app_public.iter().any(|r| r.is_site()) {
1624 Arc::from("/")
1625 } else {
1626 Arc::from(admin_path.as_str())
1627 },
1628 assets: Arc::new(assets),
1629 asset_urls: asset_urls_arc.clone(),
1630 pickers,
1631 palette: Arc::new(palette_sources),
1632 overrides: Arc::new(field::NoOverrides),
1633 };
1634
1635 let mut protected = Router::new().route(&admin_path, get(dashboard));
1636 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
1640 for resource in &resources {
1641 let (mounted, prepared) = mount_resource(
1642 resource,
1643 &state.field_types,
1644 &state.column_types,
1645 &permission_codes,
1646 &persisters,
1647 &app_listeners,
1648 );
1649 protected = protected.merge(mounted);
1650 if let Some(prepared) = prepared {
1651 let key = resource
1652 .base_path
1653 .strip_prefix(admin_path.as_str())
1654 .unwrap_or(&resource.base_path);
1655 forms.insert(key.to_string(), prepared);
1656 }
1657 }
1658 let forms: form::Forms = Arc::new(forms);
1659 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
1663 .plugin_defined
1664 .items::<laterite_media::DiskReg>()
1665 .into_iter()
1666 .find(|d| d.name == import::DISK)
1667 .map(|d| d.driver.clone())
1668 .unwrap_or_else(|| {
1669 Arc::new(laterite_media::LocalDisk::new(format!(
1670 "{}/{}",
1671 config.storage_dir.trim_end_matches('/'),
1672 import::DISK
1673 )))
1674 });
1675 for resource in &resources {
1676 let Some(link) = &resource.list.import else {
1677 continue;
1678 };
1679 let key = resource
1680 .base_path
1681 .strip_prefix(admin_path.as_str())
1682 .unwrap_or(&resource.base_path)
1683 .to_string();
1684 let prepared = forms.get(&key).unwrap_or_else(|| {
1685 panic!(
1686 "admin resource `{}` declares `import` but has no form to import through",
1687 resource.base_path
1688 )
1689 });
1690 let feature = import::ImportFeature {
1691 disk: private_disk.clone(),
1692 };
1693 let base = link.href.clone();
1694 let ctx = routes::RouteCtx::new(
1695 state.db.clone(),
1696 state.auth.clone(),
1697 state.field_types.clone(),
1698 state.overrides.clone(),
1699 &base,
1700 &admin_path,
1701 &state.origin,
1702 state.plugin_defined.clone(),
1703 state.auth.events().clone(),
1704 forms.clone(),
1705 );
1706 let info = routes::ResourceInfo::new(
1707 key,
1708 resource.base_path.clone(),
1709 resource.nav_label.clone(),
1710 link.permission.clone(),
1711 form::FormHandle::new(state.db.clone(), prepared.clone()),
1712 );
1713 protected = protected.merge(guard_with_permission(
1714 Router::new()
1715 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
1716 &link.permission,
1717 ));
1718 }
1719 for reg in &app_screens {
1723 let base = format!("{admin_path}{}", reg.base_path);
1724 let ctx = routes::RouteCtx::new(
1725 state.db.clone(),
1726 state.auth.clone(),
1727 state.field_types.clone(),
1728 state.overrides.clone(),
1729 &base,
1730 &admin_path,
1731 &state.origin,
1732 state.plugin_defined.clone(),
1733 state.auth.events().clone(),
1734 forms.clone(),
1735 );
1736 protected = protected.merge(guard_with_permission(
1737 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1738 ®.permission,
1739 ));
1740 }
1741
1742 protected = protected.merge(guard_with_permission(
1745 Router::new()
1746 .route(
1747 &format!("{admin_path}/roles/new"),
1748 get(roles::new_form).post(roles::create),
1749 )
1750 .route(
1751 &format!("{admin_path}/roles/{{id}}/edit"),
1752 get(roles::edit_form).post(roles::update),
1753 ),
1754 "backend.manage_roles",
1755 ));
1756 protected = protected.merge(guard_with_permission(
1759 Router::new()
1760 .route(
1761 &format!("{admin_path}/users/new"),
1762 get(users::new_form).post(users::create),
1763 )
1764 .route(
1765 &format!("{admin_path}/users/{{id}}/edit"),
1766 get(users::edit_form).post(users::update),
1767 )
1768 .route(
1769 &format!("{admin_path}/users/{{id}}/active"),
1770 post(users::set_active),
1771 )
1772 .route(
1773 &format!("{admin_path}/users/{{id}}/password"),
1774 post(users::reset_password),
1775 )
1776 .route(
1777 &format!("{admin_path}/users/{{id}}/unlock"),
1778 post(users::unlock),
1779 ),
1780 "backend.manage_users",
1781 ));
1782 protected = protected.merge(guard_with_permission(
1785 Router::new()
1786 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1787 .route(
1788 &format!("{admin_path}/plugins/toggle"),
1789 post(plugins::toggle),
1790 ),
1791 plugins::MANAGE_PERMISSION,
1792 ));
1793 protected = protected
1794 .route(&format!("{admin_path}/settings"), get(settings_index))
1795 .route(
1796 &format!("{admin_path}/preferences"),
1797 get(preferences_form).post(preferences_update),
1798 )
1799 .route(
1800 &format!("{admin_path}/preferences/password"),
1801 post(password_update),
1802 )
1803 .route(
1804 &format!("{admin_path}/preferences/menu"),
1805 post(menu_order_update),
1806 )
1807 .route(&format!("{admin_path}/palette"), get(palette::palette))
1808 .route(
1809 &format!("{admin_path}/preferences/sessions/revoke"),
1810 post(session_revoke),
1811 )
1812 .route(
1813 &format!("{admin_path}/preferences/sessions/revoke-others"),
1814 post(session_revoke_others),
1815 )
1816 .route(
1819 &format!("{admin_path}/pickers/{{source}}/search"),
1820 any(picker::search),
1821 )
1822 .route(
1823 &format!("{admin_path}/pickers/{{source}}/resolve"),
1824 any(picker::resolve),
1825 )
1826 .route(&format!("{admin_path}/logout"), post(logout));
1827
1828 for item in settings.iter().filter(|i| i.link.is_none()) {
1832 let path = format!("{admin_path}{}", item.route());
1833 let code = item.code.clone();
1834 let edit_code = code.clone();
1835 protected = protected.route(
1836 &path,
1837 get(
1838 move |State(state): State<AdminState>,
1839 Extension(shell): Extension<Shell>,
1840 Extension(user): Extension<AuthenticatedUser>| {
1841 let code = edit_code.clone();
1842 async move { settings_edit(state, shell, user, code).await }
1843 },
1844 )
1845 .post(
1846 move |State(state): State<AdminState>,
1847 Extension(shell): Extension<Shell>,
1848 Extension(user): Extension<AuthenticatedUser>,
1849 Extension(session): Extension<session::SessionHandle>,
1850 Form(data): Form<HashMap<String, String>>| {
1851 let code = code.clone();
1852 async move { settings_update(state, shell, user, session, code, data).await }
1853 },
1854 ),
1855 );
1856 }
1857
1858 let app = protected
1859 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1860 .route(
1864 &format!("{admin_path}/login"),
1865 get(login_form).post(login_submit),
1866 )
1867 .route(
1868 &format!("{admin_path}/setup"),
1869 get(setup_form).post(setup_submit),
1870 )
1871 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1872 .merge(mount_public(
1876 &app_public,
1877 &state.db,
1878 &state.auth,
1879 &state.field_types,
1880 &state.overrides,
1881 &admin_path,
1882 &state.origin,
1883 &state.plugin_defined,
1884 state.auth.events(),
1885 &forms,
1886 ));
1887 let app = match app_public.iter().find(|r| r.is_site()) {
1890 Some(site) => {
1891 let ctx = routes::RouteCtx::new(
1892 state.db.clone(),
1893 state.auth.clone(),
1894 state.field_types.clone(),
1895 state.overrides.clone(),
1896 "/",
1897 &admin_path,
1898 &state.origin,
1899 state.plugin_defined.clone(),
1900 state.auth.events().clone(),
1901 forms.clone(),
1902 );
1903 app.fallback_service(site.route.mount(&ctx))
1904 }
1905 None => app.fallback(not_found_fallback),
1906 };
1907 app
1908 .layer(middleware::from_fn_with_state(
1911 state.clone(),
1912 enforce_origin,
1913 ))
1914 .layer(middleware::from_fn_with_state(
1917 state.clone(),
1918 capture_client,
1919 ))
1920 .layer(
1925 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1926 header::CACHE_CONTROL,
1927 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1928 ),
1929 )
1930 .layer(
1934 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1935 header::VARY,
1936 axum::http::HeaderValue::from_static("HX-Request"),
1937 ),
1938 )
1939 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1940 handle_panic,
1941 ))
1942 .with_state(state)
1943}
1944
1945async fn not_found_fallback(
1948 State(state): State<AdminState>,
1949 method: axum::http::Method,
1950 uri: axum::http::Uri,
1951) -> AdminError {
1952 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
1953 AdminError::NotFound
1954}
1955
1956async fn not_found_plain() -> AdminError {
1958 AdminError::NotFound
1959}
1960
1961fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1963 let cause = err
1964 .downcast_ref::<&str>()
1965 .map(|s| s.to_string())
1966 .or_else(|| err.downcast_ref::<String>().cloned())
1967 .unwrap_or_else(|| "unknown panic".to_string());
1968 tracing::error!(panic = cause, "admin handler panicked");
1969 error::masked_500()
1970}
1971
1972pub fn normalize_path(path: &str) -> String {
1977 let trimmed = path.trim().trim_matches('/');
1978 if trimmed.is_empty() {
1979 "/admin".to_string()
1980 } else {
1981 format!("/{trimmed}")
1982 }
1983}
1984
1985#[allow(clippy::too_many_arguments)]
1989fn mount_public(
1990 regs: &[routes::PublicRouteReg],
1991 db: &Db,
1992 auth: &AuthService,
1993 field_types: &Arc<field::FieldRegistry>,
1994 overrides: &Arc<dyn field::OverrideResolver>,
1995 admin_path: &str,
1996 origin: &str,
1997 plugin_defined: &Arc<laterite_core::Registry>,
1998 events: &laterite_core::Events,
1999 forms: &form::Forms,
2000) -> Router<AdminState> {
2001 let mut router = Router::new();
2002 for reg in regs.iter().filter(|r| !r.is_site()) {
2004 let ctx = routes::RouteCtx::new(
2005 db.clone(),
2006 auth.clone(),
2007 field_types.clone(),
2008 overrides.clone(),
2009 ®.path,
2010 admin_path,
2011 origin,
2012 plugin_defined.clone(),
2013 events.clone(),
2014 forms.clone(),
2015 );
2016 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
2021 }
2022 router
2023}
2024
2025pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
2028 let old = resource.base_path.clone();
2029 resource.base_path = base.to_string();
2030 if let Some(edit_base) = &mut resource.list.edit_base {
2031 if edit_base == &old {
2032 *edit_base = base.to_string();
2033 }
2034 }
2035 if let Some(form) = &mut resource.form {
2036 if form.base_path == old {
2037 form.base_path = base.to_string();
2038 }
2039 }
2040}
2041
2042fn prefix_resource(admin_path: &str, resource: &mut Resource) {
2043 resource.base_path = format!("{admin_path}{}", resource.base_path);
2044 if let Some(edit_base) = &mut resource.list.edit_base {
2045 *edit_base = format!("{admin_path}{edit_base}");
2046 }
2047 if let Some(form) = &mut resource.form {
2048 form.base_path = format!("{admin_path}{}", form.base_path);
2049 }
2050}
2051
2052pub(crate) struct AdminAsset {
2054 pub mime: &'static str,
2055 pub bytes: std::borrow::Cow<'static, [u8]>,
2059}
2060
2061pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2064
2065pub(crate) type AssetUrls = HashMap<&'static str, String>;
2069
2070pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2072 registry
2073 .iter()
2074 .map(|(&key, asset)| {
2075 (
2076 key,
2077 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2078 )
2079 })
2080 .collect()
2081}
2082
2083pub(crate) fn page_assets(
2090 keys: &[&str],
2091 base: &str,
2092 registry: &AssetRegistry,
2093 urls: &AssetUrls,
2094) -> Vec<PageAsset> {
2095 let mut seen = std::collections::HashSet::new();
2096 let mut out = Vec::new();
2097 for &key in keys {
2098 if !seen.insert(key) {
2099 continue;
2100 }
2101 match (registry.get(key), urls.get(key)) {
2102 (Some(asset), Some(path)) => out.push(PageAsset {
2103 url: format!("{base}/assets/{path}"),
2104 css: asset.mime.contains("css"),
2105 }),
2106 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2107 }
2108 }
2109 out
2110}
2111
2112pub(crate) fn builtin_assets() -> AssetRegistry {
2121 let font = |bytes: &'static [u8]| AdminAsset {
2122 mime: "font/woff2",
2123 bytes: std::borrow::Cow::Borrowed(bytes),
2124 };
2125 HashMap::from([
2126 (
2127 "laterite.css",
2128 AdminAsset {
2129 mime: "text/css; charset=utf-8",
2130 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2131 },
2132 ),
2133 (
2134 "laterite.js",
2135 AdminAsset {
2136 mime: "text/javascript; charset=utf-8",
2137 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2138 },
2139 ),
2140 (
2141 "vendor/htmx.min.js",
2142 AdminAsset {
2143 mime: "text/javascript; charset=utf-8",
2144 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2145 },
2146 ),
2147 (
2148 "fields/ref-picker.js",
2149 AdminAsset {
2150 mime: "text/javascript; charset=utf-8",
2151 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
2152 },
2153 ),
2154 (
2155 "fields/ref-picker.css",
2156 AdminAsset {
2157 mime: "text/css; charset=utf-8",
2158 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2159 "../assets/fields/ref-picker.css"
2160 )),
2161 },
2162 ),
2163 (
2164 "mark.svg",
2165 AdminAsset {
2166 mime: "image/svg+xml",
2167 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
2168 },
2169 ),
2170 (
2171 "mark.png",
2172 AdminAsset {
2173 mime: "image/png",
2174 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
2175 },
2176 ),
2177 (
2178 "fonts/space-grotesk-500.woff2",
2179 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
2180 ),
2181 (
2182 "fonts/space-grotesk-600.woff2",
2183 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
2184 ),
2185 (
2186 "fonts/space-grotesk-700.woff2",
2187 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
2188 ),
2189 (
2190 "fonts/ibm-plex-sans-400.woff2",
2191 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
2192 ),
2193 (
2194 "fonts/ibm-plex-sans-600.woff2",
2195 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
2196 ),
2197 (
2198 "fonts/ibm-plex-mono-400.woff2",
2199 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
2200 ),
2201 (
2202 "fonts/ibm-plex-mono-600.woff2",
2203 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
2204 ),
2205 ])
2206}
2207
2208async fn serve_asset(
2210 State(state): State<AdminState>,
2211 headers: HeaderMap,
2212 Path(path): Path<String>,
2213) -> Response {
2214 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
2218 let Some(asset) = state.assets.get(key.as_str()) else {
2219 return not_found();
2220 };
2221 let policy = if fingerprinted {
2222 http_cache::IMMUTABLE
2223 } else {
2224 http_cache::REVALIDATE
2225 };
2226 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
2227}
2228
2229fn mount_resource(
2234 resource: &Resource,
2235 field_types: &field::FieldRegistry,
2236 column_types: &list::ColumnRegistry,
2237 permission_codes: &std::collections::HashSet<String>,
2238 persisters: &persist::PersisterRegistry,
2239 listeners: &[laterite_core::ModelListenerReg],
2240) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2241 list::check(&resource.list, column_types, permission_codes)
2245 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2246 if resource.form.is_some() && resource.permission.is_none() {
2251 panic!(
2252 "admin resource `{}` has a create/edit form but no permission; a write \
2253 resource must declare a permission",
2254 resource.base_path
2255 );
2256 }
2257 let base = resource.base_path.clone();
2258 let list_path = base.clone();
2259 let mut list_cfg = resource.list.clone();
2260 if resource.list.columns.iter().any(|c| c.editable) {
2263 let Some(form_cfg) = resource.form.clone() else {
2264 panic!(
2265 "admin resource `{}` has an editable column but no form to edit through",
2266 resource.base_path
2267 );
2268 };
2269 for column in resource.list.columns.iter().filter(|c| c.editable) {
2270 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2271 panic!(
2272 "admin resource `{}`: column `{}` is editable but the form has no such field",
2273 resource.base_path, column.field
2274 );
2275 }
2276 }
2277 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2278 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2279 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2280 }
2281 let cell_cfg = list_cfg.clone();
2282 let mut router = Router::new().route(
2283 &base,
2284 get(
2285 move |State(state): State<AdminState>,
2286 Extension(shell): Extension<Shell>,
2287 Query(params): Query<list::ListParams>,
2288 Query(raw): Query<std::collections::HashMap<String, String>>,
2289 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2290 Extension(user): Extension<AuthenticatedUser>,
2291 Extension(session): Extension<session::SessionHandle>,
2292 headers: axum::http::HeaderMap| {
2293 let cfg = list_cfg.clone();
2294 let path = list_path.clone();
2295 async move {
2296 list::handle_remembering(
2297 &state,
2298 &cfg,
2299 &path,
2300 params,
2301 &raw,
2302 &user,
2303 shell,
2304 &headers,
2305 query_string.as_deref(),
2306 Some(&session),
2307 )
2308 .await
2309 }
2310 },
2311 ),
2312 );
2313
2314 if cell_cfg.editor.is_some() {
2315 let (cfg, list_path) = (cell_cfg, base.clone());
2316 router = router.route(
2317 &format!("{base}/{{id}}/cell"),
2318 post(
2319 move |State(state): State<AdminState>,
2320 Extension(shell): Extension<Shell>,
2321 Extension(user): Extension<AuthenticatedUser>,
2322 Extension(session): Extension<session::SessionHandle>,
2323 Path(id): Path<String>,
2324 headers: axum::http::HeaderMap,
2325 Form(data): Form<HashMap<String, String>>| {
2326 let (cfg, path) = (cfg.clone(), list_path.clone());
2327 async move {
2328 list::cell_handler(
2329 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2330 )
2331 .await
2332 }
2333 },
2334 ),
2335 );
2336 }
2337
2338 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2341 if resource.list.exportable {
2342 router = router.route(
2343 &format!("{base}/export"),
2344 get(
2345 move |state: State<AdminState>,
2346 Query(raw): Query<std::collections::HashMap<String, String>>,
2347 Extension(user): Extension<AuthenticatedUser>,
2348 Extension(shell): Extension<Shell>,
2349 Extension(session): Extension<session::SessionHandle>| {
2350 let cfg = export_cfg.clone();
2351 let path = export_path.clone();
2352 async move {
2353 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2354 }
2355 },
2356 ),
2357 );
2358 }
2359
2360 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2361 router = router.route(
2362 &format!("{base}/columns"),
2363 post(
2364 move |State(state): State<AdminState>,
2365 Extension(user): Extension<AuthenticatedUser>,
2366 Extension(session): Extension<session::SessionHandle>,
2367 headers: axum::http::HeaderMap,
2368 Form(pairs): Form<Vec<(String, String)>>| {
2369 let cfg = columns_cfg.clone();
2370 let path = columns_path.clone();
2371 async move {
2372 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2373 }
2374 },
2375 ),
2376 );
2377
2378 if resource.list.deletable {
2379 let persister: Arc<dyn persist::Persister> =
2383 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
2384 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
2385 panic!(
2386 "admin resource `{}`: unregistered persister `{key}`",
2387 resource.base_path
2388 )
2389 }),
2390 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
2391 };
2392 let ctx = Arc::new(bulk::BulkContext {
2393 base_path: resource.base_path.clone(),
2394 entity: resource.list.entity.clone(),
2395 persister,
2396 listeners: listeners
2397 .iter()
2398 .filter(|reg| reg.matches(&resource.list.entity))
2399 .map(|reg| reg.listener.clone())
2400 .collect(),
2401 });
2402 router =
2403 router.route(
2404 &format!("{base}/delete"),
2405 post(
2406 move |state: State<AdminState>,
2407 user: Extension<AuthenticatedUser>,
2408 session: Extension<session::SessionHandle>,
2409 headers: axum::http::HeaderMap,
2410 form: Form<Vec<(String, String)>>| {
2411 let ctx = ctx.clone();
2412 async move {
2413 bulk::delete_handler(state, user, session, ctx, headers, form).await
2414 }
2415 },
2416 ),
2417 );
2418 }
2419
2420 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
2423 let tree = resource.list.tree.clone().unwrap_or_default();
2424 let mut move_cfg = form::FormConfig::new(
2425 resource.list.entity.clone(),
2426 laterite_core::t!("Move to"),
2427 base.clone(),
2428 resource.list.id_field.clone(),
2429 vec![form::FormField::reference(
2430 &tree.parent_key,
2431 laterite_core::t!("New parent"),
2432 &source,
2433 )
2434 .help(laterite_core::t!("Leave empty for the top level."))],
2435 );
2436 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
2437 move_cfg = move_cfg.persist(persist);
2438 }
2439 move_cfg.done_path = Some(base.clone());
2440 let move_form = Arc::new(
2441 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
2442 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
2443 );
2444 let (cfg, list_path) = (resource.list.clone(), base.clone());
2445 let (form_get, form_post) = (move_form.clone(), move_form);
2446 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
2447 router = router.route(
2448 &format!("{base}/{{id}}/move"),
2449 get(
2450 move |State(state): State<AdminState>,
2451 Extension(shell): Extension<Shell>,
2452 Path(id): Path<String>| {
2453 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
2454 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
2455 },
2456 )
2457 .post(
2458 move |State(state): State<AdminState>,
2459 Extension(shell): Extension<Shell>,
2460 Extension(user): Extension<AuthenticatedUser>,
2461 Extension(session): Extension<session::SessionHandle>,
2462 Path(id): Path<String>,
2463 headers: axum::http::HeaderMap,
2464 Form(data): Form<HashMap<String, String>>| {
2465 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
2466 async move {
2467 list::move_record(
2468 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
2469 )
2470 .await
2471 }
2472 },
2473 ),
2474 );
2475 }
2476
2477 if resource.list.reorder.is_some() {
2478 let (cfg, list_path) = (resource.list.clone(), base.clone());
2479 router = router.route(
2480 &format!("{base}/reorder"),
2481 post(
2482 move |State(state): State<AdminState>,
2483 Extension(shell): Extension<Shell>,
2484 Extension(user): Extension<AuthenticatedUser>,
2485 Extension(session): Extension<session::SessionHandle>,
2486 headers: axum::http::HeaderMap,
2487 Form(pairs): Form<Vec<(String, String)>>| {
2488 let cfg = cfg.clone();
2489 let path = list_path.clone();
2490 async move {
2491 list::reorder_handler(
2492 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
2493 &headers, &pairs,
2494 )
2495 .await
2496 }
2497 },
2498 ),
2499 );
2500 }
2501
2502 if !resource.relations.is_empty() && resource.form.is_none() {
2505 panic!(
2506 "admin resource `{}` declares relations but has no form to show them on",
2507 resource.base_path
2508 );
2509 }
2510 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
2511 for rel in &resource.relations {
2512 let prepared = relation::PreparedRelation::prepare(
2513 resource,
2514 rel.clone(),
2515 field_types,
2516 column_types,
2517 permission_codes,
2518 persisters,
2519 listeners,
2520 )
2521 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2522 relations.push(Arc::new(prepared));
2523 }
2524 let relations = Arc::new(relations);
2525
2526 let mut prepared_form = None;
2527 if let Some(form_cfg) = resource.form.clone() {
2528 let mut prepared =
2531 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2532 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2533 prepared.stay = !relations.is_empty();
2535 let prepared = Arc::new(prepared);
2536 prepared_form = Some(prepared.clone());
2537 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
2538 let relations_for_new = relations.clone();
2539 router = router.route(
2540 &format!("{base}/new"),
2541 get(
2542 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
2543 let pf = new_pf.clone();
2544 let relations = relations_for_new.clone();
2545 async move {
2546 let pending = relations
2547 .iter()
2548 .map(|r| shell.tt(&r.config.title))
2549 .collect();
2550 form::new_form(&state, &pf, shell, pending)
2551 }
2552 },
2553 )
2554 .post(
2555 move |State(state): State<AdminState>,
2556 Extension(shell): Extension<Shell>,
2557 Extension(user): Extension<AuthenticatedUser>,
2558 Extension(session): Extension<session::SessionHandle>,
2559 headers: axum::http::HeaderMap,
2560 Form(data): Form<HashMap<String, String>>| {
2561 let pf = create_pf.clone();
2562 async move {
2563 form::create(&state, &pf, data, shell, &user, &session, &headers).await
2564 }
2565 },
2566 ),
2567 );
2568
2569 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
2570 let relations_for_edit = relations.clone();
2571 router = router.route(
2572 &format!("{base}/{{id}}/edit"),
2573 get(
2574 move |State(state): State<AdminState>,
2575 Extension(shell): Extension<Shell>,
2576 Extension(user): Extension<AuthenticatedUser>,
2577 Path(id): Path<String>| {
2578 let pf = edit_pf.clone();
2579 let relations = relations_for_edit.clone();
2580 async move {
2581 let mut sections = Vec::new();
2582 for rel in relations.iter() {
2583 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
2584 sections.push(section);
2585 }
2586 }
2587 form::edit_form(&state, &pf, id, shell, sections).await
2588 }
2589 },
2590 )
2591 .post(
2592 move |State(state): State<AdminState>,
2593 Extension(shell): Extension<Shell>,
2594 Extension(user): Extension<AuthenticatedUser>,
2595 Extension(session): Extension<session::SessionHandle>,
2596 Path(id): Path<String>,
2597 headers: axum::http::HeaderMap,
2598 Form(data): Form<HashMap<String, String>>| {
2599 let pf = update_pf.clone();
2600 async move {
2601 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
2602 }
2603 },
2604 ),
2605 );
2606 }
2607
2608 for rel in relations.iter() {
2609 let name = rel.config.name.clone();
2610 let mut routes: Router<AdminState> = Router::new();
2611 let r = rel.clone();
2612 routes =
2613 routes.route(
2614 &format!("{base}/{{id}}/{name}"),
2615 get(
2616 move |state: State<AdminState>,
2617 shell: Extension<Shell>,
2618 user: Extension<AuthenticatedUser>,
2619 id: Path<String>,
2620 params: Query<list::ListParams>,
2621 raw: Query<HashMap<String, String>>,
2622 headers: axum::http::HeaderMap| {
2623 let r = r.clone();
2624 async move {
2625 relation::index(state, shell, user, id, params, raw, headers, r).await
2626 }
2627 },
2628 ),
2629 );
2630 let (r_new, r_create) = (rel.clone(), rel.clone());
2631 routes = routes.route(
2632 &format!("{base}/{{id}}/{name}/new"),
2633 get(
2634 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
2635 let r = r_new.clone();
2636 async move { relation::new_form(state, shell, id, r).await }
2637 },
2638 )
2639 .post(
2640 move |state: State<AdminState>,
2641 shell: Extension<Shell>,
2642 user: Extension<AuthenticatedUser>,
2643 session: Extension<session::SessionHandle>,
2644 id: Path<String>,
2645 headers: axum::http::HeaderMap,
2646 data: Form<HashMap<String, String>>| {
2647 let r = r_create.clone();
2648 async move {
2649 relation::create(state, shell, user, session, id, headers, data, r).await
2650 }
2651 },
2652 ),
2653 );
2654 let (r_edit, r_update) = (rel.clone(), rel.clone());
2655 routes = routes.route(
2656 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
2657 get(
2658 move |state: State<AdminState>,
2659 shell: Extension<Shell>,
2660 ids: Path<(String, String)>| {
2661 let r = r_edit.clone();
2662 async move { relation::edit_form(state, shell, ids, r).await }
2663 },
2664 )
2665 .post(
2666 move |state: State<AdminState>,
2667 shell: Extension<Shell>,
2668 user: Extension<AuthenticatedUser>,
2669 session: Extension<session::SessionHandle>,
2670 ids: Path<(String, String)>,
2671 headers: axum::http::HeaderMap,
2672 data: Form<HashMap<String, String>>| {
2673 let r = r_update.clone();
2674 async move {
2675 relation::update(state, shell, user, session, ids, headers, data, r).await
2676 }
2677 },
2678 ),
2679 );
2680 let r_delete = rel.clone();
2681 routes = routes.route(
2682 &format!("{base}/{{id}}/{name}/delete"),
2683 post(
2684 move |state: State<AdminState>,
2685 shell: Extension<Shell>,
2686 user: Extension<AuthenticatedUser>,
2687 session: Extension<session::SessionHandle>,
2688 id: Path<String>,
2689 headers: axum::http::HeaderMap,
2690 pairs: Form<Vec<(String, String)>>| {
2691 let r = r_delete.clone();
2692 async move {
2693 relation::delete(state, shell, user, session, id, headers, pairs, r).await
2694 }
2695 },
2696 ),
2697 );
2698 if rel.reorderable() {
2699 let r_reorder = rel.clone();
2700 routes = routes.route(
2701 &format!("{base}/{{id}}/{name}/reorder"),
2702 post(
2703 move |state: State<AdminState>,
2704 shell: Extension<Shell>,
2705 user: Extension<AuthenticatedUser>,
2706 session: Extension<session::SessionHandle>,
2707 id: Path<String>,
2708 headers: axum::http::HeaderMap,
2709 pairs: Form<Vec<(String, String)>>| {
2710 let r = r_reorder.clone();
2711 async move {
2712 relation::reorder(state, shell, user, session, id, headers, pairs, r)
2713 .await
2714 }
2715 },
2716 ),
2717 );
2718 }
2719 if let Some(permission) = &rel.config.permission {
2720 routes = guard_with_permission(routes, permission);
2721 }
2722 router = router.merge(routes);
2723 }
2724
2725 if let Some(permission) = &resource.permission {
2727 router = guard_with_permission(router, permission);
2728 }
2729 (router, prepared_form)
2730}
2731
2732fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
2737 let needed: Arc<str> = Arc::from(permission);
2738 router.route_layer(middleware::from_fn(
2739 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
2740 let needed = needed.clone();
2741 async move {
2742 if user.allows(&needed) {
2743 next.run(req).await
2744 } else {
2745 forbidden()
2746 }
2747 }
2748 },
2749 ))
2750}
2751
2752async fn require_auth(
2758 State(state): State<AdminState>,
2759 jar: CookieJar,
2760 request: Request,
2761 next: Next,
2762) -> Response {
2763 let login = format!("{}/login", state.admin_path);
2764 let client_ctx = request
2765 .extensions()
2766 .get::<RequestContext>()
2767 .cloned()
2768 .unwrap_or_default();
2769 let mut ended: Option<laterite_auth::SessionEnd> = None;
2774 let live = match jar.get(SESSION_COOKIE) {
2775 Some(cookie) => {
2776 let token = cookie.value().to_string();
2777 match state.auth.resolve_session(&token).await {
2778 Ok(resolved) => Some((token, resolved)),
2779 Err(_) => {
2780 ended = state.auth.session_end(&token).await.ok().flatten();
2781 None
2782 }
2783 }
2784 }
2785 None => None,
2786 };
2787 let htmx = request.headers().contains_key("hx-request");
2788 let (token, resolved, recalled) = match live {
2789 Some((token, resolved)) => (token, resolved, None),
2790 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2793 return signed_out(&state, jar, &login, ended, htmx);
2794 }
2795 None => match recall(&state, &jar, &client_ctx).await {
2796 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2797 None => {
2798 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2802 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2803 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2804 }
2805 }
2806 return signed_out(&state, jar, &login, ended, htmx);
2807 }
2808 },
2809 };
2810 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2811
2812 let mut request = request;
2817 let safe = session::is_safe_method(request.method());
2818 let mut deferred: Option<session::CsrfPending> = None;
2820 if !safe {
2821 let (parts, body) = request.into_parts();
2822 let (body, submitted) = if session::is_multipart(&parts.headers) {
2829 let token = session::header_token(&parts.headers)
2830 .or_else(|| session::query_token(parts.uri.query()));
2831 if token.is_none() {
2836 deferred = Some(session::CsrfPending::default());
2837 }
2838 (body, token)
2839 } else {
2840 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2841 .await
2842 .unwrap_or_default();
2843 let token = session::submitted_token(&parts.headers, &bytes);
2844 (Body::from(bytes), token)
2845 };
2846 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2847 {
2848 tracing::warn!(
2849 user_id = resolved.identity.user.id,
2850 "admin CSRF check failed"
2851 );
2852 return error::csrf_rejected();
2853 }
2854 request = Request::from_parts(parts, body);
2855 }
2856
2857 let user = resolved.identity;
2858 let acting_user_id = user.user.id;
2860 let path = request.uri().path().to_string();
2861 let preferences = format!("{}/preferences", state.admin_path);
2864 if !path.starts_with(&preferences)
2865 && path != format!("{}/logout", state.admin_path)
2866 && state
2867 .auth
2868 .must_change_password(user.user.id)
2869 .await
2870 .unwrap_or(false)
2871 {
2872 let to = format!("{preferences}#password");
2873 let response = if htmx {
2874 (
2875 [("HX-Redirect", to.as_str())],
2876 axum::http::StatusCode::NO_CONTENT,
2877 )
2878 .into_response()
2879 } else {
2880 Redirect::to(&to).into_response()
2881 };
2882 return with_recall_cookies(response, recalled, jar, token, &state);
2883 }
2884 let flash = if safe {
2887 handle.take_flash()
2888 } else {
2889 Vec::new()
2890 };
2891 let accept_language = request
2895 .headers()
2896 .get("accept-language")
2897 .and_then(|v| v.to_str().ok());
2898 let serveable = state.catalogs.locales();
2899 let chain = resolve_locale_chain(
2900 user.user.locale.as_deref(),
2901 accept_language,
2902 &state.default_locale,
2903 &serveable,
2904 );
2905 let i18n = Translator::with_chain(chain, state.catalogs.clone());
2906 let translator = i18n.clone();
2909 let own_order =
2911 laterite_auth::store::user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
2912 .await
2913 .unwrap_or_default();
2914 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
2915 &state.nav,
2916 &state.settings,
2917 &state.admin_path,
2918 &user.permissions,
2919 own_order.as_deref(),
2920 &path,
2921 &i18n,
2922 icons::Icons::new(&state.icons, &state.sprite_url),
2923 );
2924 let brand = state.brand().await;
2925 let shell = Shell::new(
2926 &state.admin_path,
2927 brand,
2928 &visible_nav,
2929 &user,
2930 state.timezone,
2931 sidebar,
2932 active_nav.as_deref(),
2933 handle.csrf_token(),
2934 flash,
2935 i18n,
2936 state.asset_urls.clone(),
2937 state.origin.to_string(),
2938 state.icons.clone(),
2939 state.sprite_url.clone(),
2940 );
2941 request.extensions_mut().insert(user);
2942 request.extensions_mut().insert(shell);
2943 request.extensions_mut().insert(handle.clone());
2944 if let Some(pending) = &deferred {
2946 request.extensions_mut().insert(pending.clone());
2947 }
2948 let mut response = next.run(request).await;
2949
2950 if let Some(pending) = &deferred {
2955 if !pending.was_satisfied() {
2956 tracing::error!(
2957 user_id = acting_user_id,
2958 "a multipart route returned without verifying its request token"
2959 );
2960 return error::csrf_rejected();
2961 }
2962 }
2963
2964 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2968 response = error::localized_error(meta.kind, &translator);
2969 }
2970
2971 if let Some(blob) = handle.dirty_blob() {
2974 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2975 tracing::error!(error = %e, "persisting admin session failed");
2976 }
2977 }
2978 with_recall_cookies(response, recalled, jar, token, &state)
2979}
2980
2981fn with_recall_cookies(
2985 response: Response,
2986 recalled: Option<laterite_auth::RememberCredential>,
2987 jar: CookieJar,
2988 token: String,
2989 state: &AdminState,
2990) -> Response {
2991 match recalled {
2992 Some(credential) => {
2993 let jar = jar
2994 .add(session_cookie(
2995 token,
2996 &state.cookie_path,
2997 state.secure_cookie,
2998 ))
2999 .add(remember_cookie(
3000 credential,
3001 &state.cookie_path,
3002 state.secure_cookie,
3003 ));
3004 (jar, response).into_response()
3005 }
3006 None => response,
3007 }
3008}
3009
3010async fn recall(
3014 state: &AdminState,
3015 jar: &CookieJar,
3016 ctx: &RequestContext,
3017) -> Option<(
3018 String,
3019 laterite_auth::ResolvedSession,
3020 laterite_auth::RememberCredential,
3021)> {
3022 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
3023 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
3024 let token = recalled.session.token.clone();
3025 let resolved = state.auth.resolve_session(&token).await.ok()?;
3026 Some((token, resolved, recalled.remember))
3027}
3028
3029const MAX_FORM_BYTES: usize = 1024 * 1024;
3032
3033async fn capture_client(
3041 State(state): State<AdminState>,
3042 mut request: Request,
3043 next: Next,
3044) -> Response {
3045 let peer = request
3046 .extensions()
3047 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3048 .map(|info| info.0);
3049 let headers = request.headers();
3050 let ctx = RequestContext {
3051 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3052 user_agent: clientip::user_agent(headers),
3053 };
3054 request.extensions_mut().insert(ctx);
3055 next.run(request).await
3056}
3057
3058async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3059 if !session::is_safe_method(request.method())
3060 && !session::origin_ok(request.headers(), &state.origin)
3061 {
3062 tracing::warn!(
3063 path = request.uri().path(),
3064 "admin CSRF origin check failed"
3065 );
3066 return error::csrf_rejected();
3067 }
3068 next.run(request).await
3069}
3070
3071#[derive(Deserialize)]
3072struct LoginQuery {
3073 ended: Option<String>,
3074}
3075
3076async fn login_form(
3077 State(state): State<AdminState>,
3078 headers: HeaderMap,
3079 Query(query): Query<LoginQuery>,
3080) -> Response {
3081 match state.auth.has_any_operator().await {
3083 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
3084 Ok(true) => {
3085 let i18n = pre_auth_translator(&state, &headers);
3086 let notice = query
3087 .ended
3088 .as_deref()
3089 .and_then(end_notice)
3090 .map(|text| i18n.t(&text));
3091 render(LoginTemplate {
3092 base: state.admin_path.to_string(),
3093 asset_urls: state.asset_urls.clone(),
3094 brand: state.brand().await,
3095 error: None,
3096 notice,
3097 i18n,
3098 })
3099 }
3100 Err(_) => render_error(),
3101 }
3102}
3103
3104#[derive(Deserialize)]
3105struct LoginForm {
3106 username: String,
3107 password: String,
3108 #[serde(default)]
3110 remember: Option<String>,
3111}
3112
3113async fn login_submit(
3114 State(state): State<AdminState>,
3115 jar: CookieJar,
3116 headers: HeaderMap,
3117 Extension(ctx): Extension<RequestContext>,
3118 Form(form): Form<LoginForm>,
3119) -> Response {
3120 match state
3121 .auth
3122 .authenticate(&form.username, &form.password, &ctx)
3123 .await
3124 {
3125 Ok(session) => {
3126 let wants_remember = form.remember.is_some();
3127 let user_id = session.user_id;
3128 let mut jar = jar.add(session_cookie(
3129 session.token,
3130 &state.cookie_path,
3131 state.secure_cookie,
3132 ));
3133 if wants_remember {
3134 match state.auth.issue_remember(user_id).await {
3135 Ok(credential) => {
3136 jar = jar.add(remember_cookie(
3137 credential,
3138 &state.cookie_path,
3139 state.secure_cookie,
3140 ));
3141 }
3142 Err(e) => {
3144 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
3145 }
3146 }
3147 }
3148 (jar, Redirect::to(&state.admin_path)).into_response()
3149 }
3150 Err(_) => {
3151 let i18n = pre_auth_translator(&state, &headers);
3152 render(LoginTemplate {
3153 base: state.admin_path.to_string(),
3154 asset_urls: state.asset_urls.clone(),
3155 brand: state.brand().await,
3156 error: Some(i18n.t(&t!("Invalid username or password."))),
3157 notice: None,
3158 i18n,
3159 })
3160 }
3161 }
3162}
3163
3164fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
3174 Cookie::build((SESSION_COOKIE, token))
3175 .path(admin_path.to_string())
3176 .http_only(true)
3177 .secure(secure)
3178 .same_site(SameSite::Lax)
3179 .build()
3180}
3181
3182fn remember_cookie(
3185 credential: laterite_auth::RememberCredential,
3186 admin_path: &str,
3187 secure: bool,
3188) -> Cookie<'static> {
3189 let seconds = (credential.expires_at - chrono::Utc::now())
3190 .num_seconds()
3191 .max(0);
3192 Cookie::build((REMEMBER_COOKIE, credential.cookie))
3193 .path(admin_path.to_string())
3194 .http_only(true)
3195 .secure(secure)
3196 .same_site(SameSite::Lax)
3197 .max_age(cookie::time::Duration::seconds(seconds))
3198 .build()
3199}
3200
3201fn signed_out(
3208 state: &AdminState,
3209 jar: CookieJar,
3210 login: &str,
3211 ended: Option<laterite_auth::SessionEnd>,
3212 htmx: bool,
3213) -> Response {
3214 let to = match ended.map(laterite_auth::SessionEnd::code) {
3215 Some(code) => format!("{login}?ended={code}"),
3216 None => login.to_string(),
3217 };
3218 let jar = jar
3219 .remove(
3220 Cookie::build((SESSION_COOKIE, ""))
3221 .path(state.cookie_path.to_string())
3222 .build(),
3223 )
3224 .remove(remember_removal(&state.cookie_path));
3225 if htmx {
3226 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
3227 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
3228 response.headers_mut().insert("hx-redirect", value);
3229 }
3230 return response;
3231 }
3232 (jar, Redirect::to(&to)).into_response()
3233}
3234
3235fn end_notice(code: &str) -> Option<Text> {
3238 Some(match code {
3239 "password_changed" => {
3240 t!("Your password was changed, so you were signed out. Sign in with the new one.")
3241 }
3242 "deactivated" => t!("This account was deactivated."),
3243 "signed_out_elsewhere" => t!("You were signed out from another device."),
3244 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3245 _ => return None,
3246 })
3247}
3248
3249fn remember_removal(admin_path: &str) -> Cookie<'static> {
3251 Cookie::build((REMEMBER_COOKIE, ""))
3252 .path(admin_path.to_string())
3253 .build()
3254}
3255
3256#[derive(Deserialize)]
3257struct SetupForm {
3258 username: String,
3259 first_name: String,
3260 last_name: String,
3261 email: String,
3262 password: String,
3263 timezone: String,
3264}
3265
3266async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3269 match state.auth.has_any_operator().await {
3270 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3271 Ok(false) => render(setup_view(
3272 &state.admin_path,
3273 state.brand().await,
3274 state.timezone,
3275 None,
3276 pre_auth_translator(&state, &headers),
3277 state.asset_urls.clone(),
3278 state.auth.password_policy().min_length,
3279 )),
3280 Err(_) => render_error(),
3281 }
3282}
3283
3284async fn setup_submit(
3285 State(state): State<AdminState>,
3286 jar: CookieJar,
3287 headers: HeaderMap,
3288 client: Option<Extension<RequestContext>>,
3289 Form(form): Form<SetupForm>,
3290) -> Response {
3291 match state.auth.has_any_operator().await {
3293 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3294 Ok(false) => {}
3295 Err(_) => return render_error(),
3296 }
3297
3298 let username = form.username.trim();
3299 let email = form.email.trim();
3300 let first_name = form.first_name.trim();
3301 let last_name = form.last_name.trim();
3302 let tz = form.timezone.trim();
3303 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3304 {
3305 return render(setup_view(
3306 &state.admin_path,
3307 state.brand().await,
3308 state.timezone,
3309 Some(t!(
3310 "Username, first name, email, and password are all required."
3311 )),
3312 pre_auth_translator(&state, &headers),
3313 state.asset_urls.clone(),
3314 state.auth.password_policy().min_length,
3315 ));
3316 }
3317 if tz.parse::<Tz>().is_err() {
3319 return render(setup_view(
3320 &state.admin_path,
3321 state.brand().await,
3322 state.timezone,
3323 Some(t!("That is not a recognised timezone.")),
3324 pre_auth_translator(&state, &headers),
3325 state.asset_urls.clone(),
3326 state.auth.password_policy().min_length,
3327 ));
3328 }
3329
3330 let min_length = state.auth.password_policy().min_length;
3331 if form.password.chars().count() < min_length {
3332 return render(setup_view(
3333 &state.admin_path,
3334 state.brand().await,
3335 state.timezone,
3336 Some(t!(
3337 "A password needs at least {n} characters.",
3338 n = min_length as i64
3339 )),
3340 pre_auth_translator(&state, &headers),
3341 state.asset_urls.clone(),
3342 state.auth.password_policy().min_length,
3343 ));
3344 }
3345 let new = NewOperator {
3346 username,
3347 email,
3348 first_name,
3349 last_name: (!last_name.is_empty()).then_some(last_name),
3350 password: &form.password,
3351 timezone: Some(tz),
3352 };
3353 let id = match state.auth.create_superuser(new).await {
3354 Ok(id) => id,
3355 Err(_) => {
3356 return render(setup_view(
3357 &state.admin_path,
3358 state.brand().await,
3359 state.timezone,
3360 Some(t!(
3361 "Could not create the account. The username or email may already be taken."
3362 )),
3363 pre_auth_translator(&state, &headers),
3364 state.asset_urls.clone(),
3365 state.auth.password_policy().min_length,
3366 ));
3367 }
3368 };
3369 let actor = laterite_core::Actor::system("first-run setup");
3372 let target = id.to_string();
3373 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
3374 "backend_user",
3375 &target,
3376 Some(username),
3377 );
3378 if let Err(e) = state.auth.record_audit(entry).await {
3379 tracing::error!(error = %e, "failed to write audit log entry");
3380 }
3381
3382 match state
3384 .auth
3385 .authenticate(
3386 username,
3387 &form.password,
3388 &client.map(|c| c.0).unwrap_or_default(),
3389 )
3390 .await
3391 {
3392 Ok(session) => {
3393 let cookie = session_cookie(session.token, &state.cookie_path, state.secure_cookie);
3394 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
3395 }
3396 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3397 }
3398}
3399
3400fn setup_view(
3403 admin_path: &str,
3404 brand: String,
3405 default_tz: Tz,
3406 error: Option<Text>,
3407 i18n: Translator,
3408 asset_urls: Arc<AssetUrls>,
3409 min_password: usize,
3410) -> SetupTemplate {
3411 let default_name = default_tz.name();
3412 let zones = TZ_VARIANTS
3413 .iter()
3414 .filter(|tz| zone_offered(tz.name()))
3415 .map(|tz| TzOption {
3416 name: tz.name().to_string(),
3417 selected: tz.name() == default_name,
3418 })
3419 .collect();
3420 SetupTemplate {
3421 base: admin_path.to_string(),
3422 asset_urls,
3423 brand,
3424 zones,
3425 error: error.map(|e| i18n.t(&e)),
3426 min_password: min_password as i64,
3427 i18n,
3428 }
3429}
3430
3431async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
3432 if let Some(cookie) = jar.get(SESSION_COOKIE) {
3433 let _ = state.auth.logout(cookie.value()).await;
3434 }
3435 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3438 let _ = state.auth.revoke_remember(cookie.value()).await;
3439 }
3440 let removal = Cookie::build((SESSION_COOKIE, ""))
3441 .path(state.cookie_path.to_string())
3442 .build();
3443 (
3444 jar.remove(removal)
3445 .remove(remember_removal(&state.cookie_path)),
3446 Redirect::to(&format!("{}/login", state.admin_path)),
3447 )
3448 .into_response()
3449}
3450
3451async fn dashboard(
3452 Extension(shell): Extension<Shell>,
3453 Extension(user): Extension<AuthenticatedUser>,
3454) -> Response {
3455 render(DashboardTemplate {
3456 username: user.user.username,
3457 shell,
3458 })
3459}
3460
3461fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
3464 match &item.permission {
3465 None => true,
3466 Some(p) => perms.allows(p),
3467 }
3468}
3469
3470fn visible_settings(
3474 items: &[settings::SettingsItem],
3475 perms: &PermissionSet,
3476) -> Vec<settings::SettingsItem> {
3477 items
3478 .iter()
3479 .filter(|item| operator_can_see(item, perms))
3480 .cloned()
3481 .collect()
3482}
3483
3484async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
3485 settings::index(shell)
3486}
3487
3488async fn settings_edit(
3489 state: AdminState,
3490 shell: Shell,
3491 user: AuthenticatedUser,
3492 code: String,
3493) -> Response {
3494 let items = visible_settings(&state.settings, &user.permissions);
3497 match items
3498 .iter()
3499 .find(|item| item.code == code && item.link.is_none())
3500 {
3501 Some(item) => settings::edit_form(&state, item, shell, &user).await,
3502 None => not_found(),
3503 }
3504}
3505
3506async fn settings_update(
3507 state: AdminState,
3508 shell: Shell,
3509 user: AuthenticatedUser,
3510 session: session::SessionHandle,
3511 code: String,
3512 data: HashMap<String, String>,
3513) -> Response {
3514 let items = visible_settings(&state.settings, &user.permissions);
3515 match items
3516 .iter()
3517 .find(|item| item.code == code && item.link.is_none())
3518 {
3519 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
3520 None => not_found(),
3521 }
3522}
3523
3524async fn preferences_form(
3526 State(state): State<AdminState>,
3527 Extension(shell): Extension<Shell>,
3528 Extension(user): Extension<AuthenticatedUser>,
3529 jar: CookieJar,
3530) -> Response {
3531 let account = account_security(&state, &user, &shell, &jar).await;
3532 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
3533 .into_iter()
3534 .filter(|i| i.per_operator)
3535 .map(|i| PreferenceLink {
3536 label: shell.tt(&i.label),
3537 description: shell.tt(&i.description),
3538 href: i.path(&state.admin_path),
3539 })
3540 .collect();
3541 render(preferences_view(
3542 &shell,
3543 &user,
3544 state.timezone,
3545 &state.default_locale,
3546 &offered_locales(&state.catalogs),
3547 account,
3548 state.auth.password_policy().min_length,
3549 items,
3550 ))
3551}
3552
3553struct PreferenceLink {
3555 label: String,
3556 description: String,
3557 href: String,
3558}
3559
3560#[derive(Deserialize)]
3561struct RevokeSessionForm {
3562 id: String,
3563}
3564
3565async fn session_revoke(
3567 State(state): State<AdminState>,
3568 Extension(user): Extension<AuthenticatedUser>,
3569 Extension(session): Extension<session::SessionHandle>,
3570 Form(form): Form<RevokeSessionForm>,
3571) -> Response {
3572 match state.auth.revoke_session(user.user.id, &form.id).await {
3573 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
3574 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
3575 Err(e) => {
3576 tracing::error!(error = %e, "revoking a session failed");
3577 session.push_flash(
3578 FlashLevel::Error,
3579 t!("That session could not be signed out."),
3580 );
3581 }
3582 }
3583 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3584}
3585
3586#[derive(Deserialize)]
3588struct MenuOrderForm {
3589 #[serde(default)]
3590 order: String,
3591}
3592
3593async fn menu_order_update(
3598 State(state): State<AdminState>,
3599 Extension(user): Extension<AuthenticatedUser>,
3600 Extension(session): Extension<session::SessionHandle>,
3601 headers: axum::http::HeaderMap,
3602 Form(form): Form<MenuOrderForm>,
3603) -> Response {
3604 let order: Vec<&str> = form
3605 .order
3606 .split(',')
3607 .map(str::trim)
3608 .filter(|k| !k.is_empty() && k.len() <= 200)
3609 .take(100)
3610 .collect();
3611 let result = if order.is_empty() {
3612 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
3613 .await
3614 } else {
3615 laterite_auth::store::set_user_preference(
3616 &state.db,
3617 user.user.id,
3618 nav::ORDER_PREFERENCE,
3619 &order.join(","),
3620 )
3621 .await
3622 };
3623 if let Err(e) = result {
3624 tracing::error!(error = %e, "storing the menu order failed");
3625 return render_error();
3626 }
3627 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
3628 return axum::http::StatusCode::NO_CONTENT.into_response();
3629 }
3630 session.push_flash(
3631 FlashLevel::Success,
3632 if order.is_empty() {
3633 t!("The menu is back in its default order.")
3634 } else {
3635 t!("Saved.")
3636 },
3637 );
3638 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3639}
3640
3641async fn session_revoke_others(
3642 State(state): State<AdminState>,
3643 Extension(user): Extension<AuthenticatedUser>,
3644 Extension(session): Extension<session::SessionHandle>,
3645 jar: CookieJar,
3646) -> Response {
3647 let token = jar
3648 .get(SESSION_COOKIE)
3649 .map(|c| c.value().to_string())
3650 .unwrap_or_default();
3651 match state.auth.sign_out_everywhere(user.user.id, &token).await {
3652 Ok(_) => session.push_flash(
3653 FlashLevel::Success,
3654 t!("Every other session was signed out."),
3655 ),
3656 Err(e) => {
3657 tracing::error!(error = %e, "signing out other sessions failed");
3658 session.push_flash(
3659 FlashLevel::Error,
3660 t!("Those sessions could not be signed out."),
3661 );
3662 }
3663 }
3664 (
3667 jar.remove(remember_removal(&state.cookie_path)),
3668 Redirect::to(&format!("{}/preferences", state.admin_path)),
3669 )
3670 .into_response()
3671}
3672
3673#[derive(Deserialize)]
3674struct PasswordForm {
3675 current_password: String,
3676 new_password: String,
3677 confirm_password: String,
3678}
3679
3680async fn password_update(
3685 State(state): State<AdminState>,
3686 Extension(user): Extension<AuthenticatedUser>,
3687 Extension(session): Extension<session::SessionHandle>,
3688 Extension(ctx): Extension<RequestContext>,
3689 jar: CookieJar,
3690 Form(form): Form<PasswordForm>,
3691) -> Response {
3692 let back = format!("{}/preferences#password", state.admin_path);
3693 let refuse = |message: Text| {
3694 session.push_flash(FlashLevel::Error, message);
3695 Redirect::to(&back).into_response()
3696 };
3697 if form.new_password != form.confirm_password {
3698 return refuse(t!("The new passwords do not match."));
3699 }
3700 let min_length = state.auth.password_policy().min_length;
3701 if form.new_password.chars().count() < min_length {
3702 return refuse(t!(
3703 "A password needs at least {n} characters.",
3704 n = min_length as i64
3705 ));
3706 }
3707 let remembered_here = match jar.get(REMEMBER_COOKIE) {
3710 Some(cookie) => {
3711 state
3712 .auth
3713 .remember_holder(cookie.value())
3714 .await
3715 .ok()
3716 .flatten()
3717 == Some(user.user.id)
3718 }
3719 None => false,
3720 };
3721 let token = jar
3722 .get(SESSION_COOKIE)
3723 .map(|c| c.value().to_string())
3724 .unwrap_or_default();
3725 let changed = state
3726 .auth
3727 .change_own_password(
3728 user.user.id,
3729 &form.current_password,
3730 &form.new_password,
3731 &token,
3732 &ctx,
3733 )
3734 .await;
3735 match changed {
3736 Ok(()) => {}
3737 Err(laterite_auth::AuthError::InvalidCredentials) => {
3738 return refuse(t!("Your current password is not correct."));
3739 }
3740 Err(laterite_auth::AuthError::TooManyAttempts) => {
3741 return refuse(t!("Too many attempts. Try again later."));
3742 }
3743 Err(e) => {
3744 tracing::error!(error = %e, "changing a password failed");
3745 return refuse(t!("The password could not be changed."));
3746 }
3747 }
3748 session.push_flash_sticky(
3749 FlashLevel::Success,
3750 t!("Password changed. Every other device was signed out."),
3751 );
3752 let jar = if remembered_here {
3753 match state.auth.issue_remember(user.user.id).await {
3754 Ok(credential) => jar.add(remember_cookie(
3755 credential,
3756 &state.cookie_path,
3757 state.secure_cookie,
3758 )),
3759 Err(e) => {
3760 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
3761 jar.remove(remember_removal(&state.cookie_path))
3762 }
3763 }
3764 } else if jar.get(REMEMBER_COOKIE).is_some() {
3765 jar.remove(remember_removal(&state.cookie_path))
3768 } else {
3769 jar
3770 };
3771 (
3772 jar,
3773 Redirect::to(&format!("{}/preferences", state.admin_path)),
3774 )
3775 .into_response()
3776}
3777
3778#[derive(Deserialize)]
3779struct PreferencesForm {
3780 timezone: String,
3781 #[serde(default)]
3783 locale: String,
3784}
3785
3786async fn preferences_update(
3787 State(state): State<AdminState>,
3788 Extension(user): Extension<AuthenticatedUser>,
3789 Extension(session): Extension<session::SessionHandle>,
3790 Form(form): Form<PreferencesForm>,
3791) -> Response {
3792 let back = format!("{}/preferences", state.admin_path);
3793 let offered = offered_locales(&state.catalogs);
3794 let tz = form.timezone.trim();
3796 let tz_stored = if tz.is_empty() {
3797 None
3798 } else if tz.parse::<Tz>().is_ok() {
3799 Some(tz)
3800 } else {
3801 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
3802 return Redirect::to(&back).into_response();
3803 };
3804 let loc = form.locale.trim();
3805 let loc_stored = if loc.is_empty() {
3806 None
3807 } else if offered.iter().any(|l| l == loc) {
3808 Some(loc)
3809 } else {
3810 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
3811 return Redirect::to(&back).into_response();
3812 };
3813 if state
3814 .auth
3815 .set_user_timezone(user.user.id, tz_stored)
3816 .await
3817 .is_err()
3818 {
3819 return render_error();
3820 }
3821 match state.auth.set_user_locale(user.user.id, loc_stored).await {
3822 Ok(()) => {
3823 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
3824 Redirect::to(&back).into_response()
3825 }
3826 Err(_) => render_error(),
3827 }
3828}
3829
3830#[allow(clippy::too_many_arguments)]
3834fn preferences_view(
3835 shell: &Shell,
3836 user: &AuthenticatedUser,
3837 default_tz: Tz,
3838 default_locale: &str,
3839 offered: &[String],
3840 account: AccountSecurity,
3841 min_password: usize,
3842 items: Vec<PreferenceLink>,
3843) -> PreferencesTemplate {
3844 let current = user.user.timezone.as_deref();
3845 let zones = TZ_VARIANTS
3846 .iter()
3847 .filter(|tz| zone_offered(tz.name()))
3848 .map(|tz| TzOption {
3849 name: tz.name().to_string(),
3850 selected: current == Some(tz.name()),
3851 })
3852 .collect();
3853 let current_locale = user.user.locale.as_deref();
3854 let locales = offered
3855 .iter()
3856 .map(|code| LocaleOption {
3857 name: locale_name(code),
3858 selected: current_locale == Some(code.as_str()),
3859 code: code.clone(),
3860 })
3861 .collect();
3862 PreferencesTemplate {
3863 shell: shell.clone(),
3864 zones,
3865 effective_tz: shell.tz.name().to_string(),
3866 default_tz: default_tz.name().to_string(),
3867 inherits: current.is_none(),
3868 locales,
3869 default_locale: locale_name(default_locale),
3870 inherits_locale: current_locale.is_none(),
3871 sessions: account.sessions,
3872 password_changed: account.password_changed,
3873 must_change: account.must_change,
3874 min_password: min_password as i64,
3875 items,
3876 }
3877}
3878
3879struct AccountSecurity {
3882 sessions: Vec<SessionRow>,
3883 password_changed: Option<String>,
3884 must_change: bool,
3886}
3887
3888async fn account_security(
3889 state: &AdminState,
3890 user: &AuthenticatedUser,
3891 shell: &Shell,
3892 jar: &CookieJar,
3893) -> AccountSecurity {
3894 let locale = list::date_locale(shell.locale());
3895 let password_changed = match state.auth.password_changed_at(user.user.id).await {
3896 Ok(at) => {
3897 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
3898 }
3899 Err(e) => {
3900 tracing::error!(error = %e, "reading the password change time failed");
3901 None
3902 }
3903 };
3904 AccountSecurity {
3905 sessions: session_rows(state, user, shell, jar).await,
3906 password_changed,
3907 must_change: state
3908 .auth
3909 .must_change_password(user.user.id)
3910 .await
3911 .unwrap_or(false),
3912 }
3913}
3914
3915async fn session_rows(
3918 state: &AdminState,
3919 user: &AuthenticatedUser,
3920 shell: &Shell,
3921 jar: &CookieJar,
3922) -> Vec<SessionRow> {
3923 let token = jar
3924 .get(SESSION_COOKIE)
3925 .map(|c| c.value().to_string())
3926 .unwrap_or_default();
3927 let locale = list::date_locale(shell.locale());
3928 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
3929 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
3930 };
3931 match state.auth.list_sessions(user.user.id, &token).await {
3932 Ok(sessions) => sessions
3933 .into_iter()
3934 .map(|s| SessionRow {
3935 id: s.id,
3936 started: stamp(s.created_at),
3937 last_seen: stamp(s.last_seen_at),
3938 expires: stamp(s.expires_at),
3939 device: clientip::describe(s.user_agent.as_deref())
3940 .or(s.user_agent)
3941 .unwrap_or_else(|| "-".to_string()),
3942 ip_address: s.ip_address.unwrap_or_default(),
3943 current: s.current,
3944 })
3945 .collect(),
3946 Err(e) => {
3947 tracing::error!(error = %e, "listing sessions failed");
3948 Vec::new()
3949 }
3950 }
3951}
3952
3953fn builtin_resources() -> Vec<Resource> {
3955 vec![
3956 Resource {
3957 base_path: "/users".to_string(),
3958 nav_label: "Administrators".into(),
3959 list: backend_users_list_config(),
3960 form: None,
3961 permission: Some("backend.manage_users".to_string()),
3962 import: None,
3963 palette: None,
3964 relations: Vec::new(),
3965 menu: nav::Placement::default(),
3966 },
3967 Resource {
3968 base_path: "/roles".to_string(),
3969 nav_label: "Roles".into(),
3970 list: roles_list_config(),
3971 form: None,
3974 permission: Some("backend.manage_roles".to_string()),
3975 import: None,
3976 palette: None,
3977 relations: Vec::new(),
3978 menu: nav::Placement::default(),
3979 },
3980 resource!("descriptors/audit-log.yaml"),
3981 resource!("descriptors/access-log.yaml"),
3982 resource!("descriptors/request-log.yaml"),
3983 ]
3984}
3985
3986fn builtin_settings() -> Vec<settings::SettingsItem> {
3990 vec![
3991 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
3992 .description("Manage backend administrator accounts.")
3993 .category("Users")
3994 .order(10)
3995 .icon("users")
3996 .permission("backend.manage_users")
3997 .link("/users"),
3998 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
3999 .description("Manage roles and their permissions.")
4000 .category("Users")
4001 .order(20)
4002 .icon("shield")
4003 .permission("backend.manage_roles")
4004 .link("/roles"),
4005 settings::brand::settings_item(),
4006 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
4007 .description("Enable or disable installed plugins.")
4008 .category("System")
4009 .order(10)
4010 .icon("plug")
4011 .permission(plugins::MANAGE_PERMISSION)
4012 .link("/plugins"),
4013 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
4014 .description("Review the record of administrative changes.")
4015 .category("System")
4016 .order(20)
4017 .icon("history")
4018 .permission("backend.view_audit_log")
4019 .link("/audit-log"),
4020 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
4021 .description("Review sign-ins, failures and lockouts.")
4022 .category("System")
4023 .order(30)
4024 .icon("log-in")
4025 .permission("backend.view_access_log")
4026 .link("/access-log"),
4027 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
4028 .description("Review the URLs the panel could not serve.")
4029 .category("System")
4030 .order(31)
4031 .icon("unlink")
4032 .permission("backend.view_request_log")
4033 .link("/request-log"),
4034 ]
4035}
4036
4037fn backend_users_list_config() -> list::ListConfig {
4038 list::ListConfig {
4039 entity: "backend_users".to_string(),
4040 title: "Administrators".into(),
4041 columns: vec![
4042 list::ListColumn::new("username", "Username"),
4043 list::ListColumn::new("email", "Email"),
4044 list::ListColumn::new("first_name", "First name"),
4045 list::ListColumn::new("last_name", "Last name"),
4046 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
4047 list::ListColumn::new("is_active", "Active").yes_no(),
4048 list::ListColumn::new("created_at", "Created").datetime(),
4049 ],
4050 order_by: "created_at".to_string(),
4051 creatable: true,
4054 edit_base: Some("/users".to_string()),
4055 filters: vec![
4056 list::ListFilter::boolean("is_active", "Active"),
4057 list::ListFilter::boolean("is_superuser", "Superuser"),
4058 ],
4059 ..Default::default()
4063 }
4064}
4065
4066fn roles_list_config() -> list::ListConfig {
4067 list::ListConfig {
4068 entity: "backend_roles".to_string(),
4069 title: "Roles".into(),
4070 columns: vec![
4071 list::ListColumn::new("code", "Code"),
4072 list::ListColumn::new("name", "Name"),
4073 list::ListColumn::new("created_at", "Created").datetime(),
4074 ],
4075 order_by: "created_at".to_string(),
4076 edit_base: Some("/roles".to_string()),
4077 creatable: true,
4078 deletable: true,
4079 ..Default::default()
4080 }
4081}
4082
4083#[derive(Template)]
4084#[template(path = "login.html")]
4085struct LoginTemplate {
4086 base: String,
4088 asset_urls: Arc<AssetUrls>,
4090 brand: String,
4091 error: Option<String>,
4092 notice: Option<String>,
4094 i18n: Translator,
4097}
4098
4099fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
4102 match urls.get(key) {
4103 Some(path) => format!("{base}/assets/{path}"),
4104 None => {
4105 debug_assert!(false, "asset key `{key}` is not registered");
4106 format!("{base}/assets/{key}")
4107 }
4108 }
4109}
4110
4111impl LoginTemplate {
4112 fn asset(&self, key: &str) -> String {
4113 preauth_asset(&self.asset_urls, &self.base, key)
4114 }
4115 fn t(&self, source: &str) -> String {
4116 self.i18n.t(&Text::dynamic(source))
4117 }
4118 fn locale(&self) -> &str {
4119 self.i18n.locale()
4120 }
4121}
4122
4123#[derive(Template)]
4124#[template(path = "dashboard.html")]
4125struct DashboardTemplate {
4126 shell: Shell,
4127 username: String,
4128}
4129
4130#[derive(Template)]
4131#[template(path = "setup.html")]
4132struct SetupTemplate {
4133 base: String,
4135 asset_urls: Arc<AssetUrls>,
4137 brand: String,
4138 zones: Vec<TzOption>,
4139 error: Option<String>,
4140 i18n: Translator,
4142 min_password: i64,
4144}
4145
4146impl SetupTemplate {
4147 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
4150 let mut text = Text::dynamic(source);
4151 for (name, value) in args {
4152 text = text.arg(*name, *value);
4153 }
4154 self.i18n.t(&text)
4155 }
4156
4157 fn asset(&self, key: &str) -> String {
4158 preauth_asset(&self.asset_urls, &self.base, key)
4159 }
4160 fn t(&self, source: &str) -> String {
4161 self.i18n.t(&Text::dynamic(source))
4162 }
4163 fn locale(&self) -> &str {
4164 self.i18n.locale()
4165 }
4166}
4167
4168#[derive(Template)]
4169#[template(path = "preferences.html")]
4170struct PreferencesTemplate {
4171 shell: Shell,
4172 zones: Vec<TzOption>,
4173 effective_tz: String,
4175 default_tz: String,
4177 inherits: bool,
4179 locales: Vec<LocaleOption>,
4180 default_locale: String,
4182 inherits_locale: bool,
4184 sessions: Vec<SessionRow>,
4186 password_changed: Option<String>,
4188 must_change: bool,
4190 min_password: i64,
4192 items: Vec<PreferenceLink>,
4194}
4195
4196struct SessionRow {
4198 id: String,
4199 started: String,
4200 last_seen: String,
4201 expires: String,
4202 device: String,
4205 ip_address: String,
4207 current: bool,
4208}
4209
4210struct TzOption {
4211 name: String,
4212 selected: bool,
4213}
4214
4215struct LocaleOption {
4216 code: String,
4218 name: String,
4220 selected: bool,
4221}
4222
4223#[derive(Clone)]
4225struct CrumbView {
4226 label: String,
4227 href: String,
4228}
4229
4230#[derive(Clone)]
4231struct NavView {
4232 key: String,
4234 label: String,
4235 path: String,
4236 active: bool,
4237 icon: String,
4240}
4241
4242pub(crate) fn render<T: Template>(template: T) -> Response {
4244 match template.render() {
4245 Ok(html) => Html(html).into_response(),
4246 Err(e) => AdminError::from(e).into_response(),
4248 }
4249}
4250
4251pub(crate) fn render_error() -> Response {
4252 error::masked_500()
4253}
4254
4255pub(crate) fn not_found() -> Response {
4256 AdminError::NotFound.into_response()
4257}
4258
4259pub(crate) fn forbidden() -> Response {
4260 AdminError::Forbidden.into_response()
4261}
4262
4263#[cfg(test)]
4264mod tests {
4265 use super::*;
4266
4267 fn write_form() -> form::FormConfig {
4268 form::FormConfig {
4269 enter: Default::default(),
4270 confirm_leave: true,
4271 focus: Default::default(),
4272 entity: "widgets".to_string(),
4273 title: "Widgets".into(),
4274 base_path: "/widgets".to_string(),
4275 id_field: "id".to_string(),
4276 fields: vec![],
4277 persist: None,
4278 timestamps: false,
4279 done_path: None,
4280 }
4281 }
4282
4283 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
4284 Resource {
4285 base_path: "/widgets".to_string(),
4286 nav_label: "Widgets".into(),
4287 list: backend_users_list_config(),
4288 form,
4289 permission: permission.map(str::to_string),
4290 import: None,
4291 palette: None,
4292 relations: Vec::new(),
4293 menu: nav::Placement::default(),
4294 }
4295 }
4296
4297 #[test]
4298 #[should_panic(expected = "must declare a permission")]
4299 fn write_resource_without_permission_aborts_boot() {
4300 let resource = resource_with(Some(write_form()), None);
4303 let _ = mount_resource(
4304 &resource,
4305 &field::builtin_registry(),
4306 &list::builtin_column_registry(),
4307 &std::collections::HashSet::new(),
4308 &persist::PersisterRegistry::new(),
4309 &[],
4310 );
4311 }
4312
4313 #[test]
4315 fn the_access_log_is_read_from_its_descriptor_file() {
4316 let access = builtin_resources()
4317 .into_iter()
4318 .find(|r| r.base_path == "/access-log")
4319 .expect("the access log is registered");
4320 assert_eq!(access.list.entity, "backend_access_log");
4321 assert_eq!(
4322 access.permission.as_deref(),
4323 Some("backend.view_access_log")
4324 );
4325 assert!(access.form.is_none(), "append-only");
4326 assert_eq!(
4327 access
4328 .list
4329 .columns
4330 .iter()
4331 .map(|c| c.field.as_str())
4332 .collect::<Vec<_>>(),
4333 [
4334 "created_at",
4335 "username_attempted",
4336 "event",
4337 "ip_address",
4338 "user_agent"
4339 ]
4340 );
4341 }
4342
4343 #[test]
4346 fn the_audit_log_is_read_from_its_descriptor_file() {
4347 let audit = builtin_resources()
4348 .into_iter()
4349 .find(|r| r.base_path == "/audit-log")
4350 .expect("the audit log is registered");
4351 assert_eq!(audit.list.entity, "backend_audit_log");
4352 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
4353 assert_eq!(
4354 audit
4355 .list
4356 .columns
4357 .iter()
4358 .map(|c| c.field.as_str())
4359 .collect::<Vec<_>>(),
4360 [
4361 "created_at",
4362 "actor_username",
4363 "action",
4364 "target_type",
4365 "target_id",
4366 "target_label"
4367 ],
4368 "in the order the file writes them"
4369 );
4370 assert_eq!(audit.list.columns[0].column_type, "datetime");
4371 assert_eq!(audit.list.per_page, 50);
4372 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
4373 assert!(audit.list.exportable, "evidence may leave the panel");
4374 assert!(audit.form.is_none(), "append-only: no form");
4375 assert!(!audit.list.deletable, "and nothing removes from it");
4376 }
4377
4378 #[test]
4379 #[should_panic(expected = "uses unregistered type `nope`")]
4380 fn unregistered_column_type_aborts_boot() {
4381 let mut resource = resource_with(None, None);
4382 let mut score = list::ListColumn::new("score", "Score");
4383 score.column_type = "nope".into();
4384 resource.list.columns.push(score);
4385 let _ = mount_resource(
4386 &resource,
4387 &field::builtin_registry(),
4388 &list::builtin_column_registry(),
4389 &std::collections::HashSet::new(),
4390 &persist::PersisterRegistry::new(),
4391 &[],
4392 );
4393 }
4394
4395 #[test]
4396 fn read_only_resource_may_omit_permission() {
4397 let resource = resource_with(None, 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]
4410 fn shell_localizes_through_the_translator_and_falls_back() {
4411 let shell = Shell::test();
4413 assert_eq!(shell.t("Save"), "Save");
4414 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
4415 }
4416
4417 #[test]
4418 fn shell_tf_interpolates_integer_arguments() {
4419 let shell = Shell::test();
4420 assert_eq!(
4421 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
4422 "Page 2 of 5"
4423 );
4424 }
4425
4426 #[test]
4427 fn display_tz_prefers_a_valid_operator_preference() {
4428 assert_eq!(
4429 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
4430 Tz::Asia__Kolkata
4431 );
4432 }
4433
4434 #[test]
4435 fn display_tz_falls_back_when_unset_or_invalid() {
4436 let default = Tz::Europe__London;
4437 assert_eq!(resolve_display_tz(None, default), default);
4439 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
4441 assert_eq!(resolve_display_tz(Some(""), default), default);
4442 }
4443
4444 #[test]
4445 fn accept_language_orders_by_weight_and_drops_wildcard() {
4446 assert_eq!(
4447 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
4448 vec!["en-US", "en", "kn"]
4449 );
4450 assert_eq!(
4452 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
4453 vec!["kn", "en"]
4454 );
4455 }
4456
4457 #[test]
4458 fn locale_chain_prefers_operator_then_header_then_default() {
4459 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
4461 assert_eq!(
4464 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
4465 vec!["kn", "ta", "hi", "en"]
4466 );
4467 assert_eq!(
4469 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
4470 vec!["kn", "en", "ta"]
4471 );
4472 assert_eq!(
4474 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
4475 vec!["kn", "en"]
4476 );
4477 }
4478
4479 #[test]
4480 fn locale_chain_skips_unserveable_and_defaults_to_en() {
4481 let loaded = ["hi".to_string()];
4483 assert_eq!(
4486 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
4487 vec!["en"]
4488 );
4489 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
4491 assert_eq!(
4493 resolve_locale_chain(Some("hi"), None, "en", &loaded),
4494 vec!["hi", "en"]
4495 );
4496 assert_eq!(
4498 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
4499 vec![laterite_core::PSEUDO_LOCALE, "en"]
4500 );
4501 }
4502
4503 #[test]
4504 fn page_assets_dedupe_preserve_order_and_classify() {
4505 let mut reg = AssetRegistry::new();
4506 reg.insert(
4507 "a.js",
4508 AdminAsset {
4509 mime: "text/javascript; charset=utf-8",
4510 bytes: std::borrow::Cow::Borrowed(b"//"),
4511 },
4512 );
4513 reg.insert(
4514 "b.css",
4515 AdminAsset {
4516 mime: "text/css; charset=utf-8",
4517 bytes: std::borrow::Cow::Borrowed(b"body{}"),
4518 },
4519 );
4520 let urls = asset_urls(®);
4523 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
4524 assert_eq!(assets.len(), 2);
4525 assert_eq!(
4526 assets[0].url,
4527 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
4528 );
4529 assert!(!assets[0].css);
4530 assert_eq!(
4531 assets[1].url,
4532 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
4533 );
4534 assert!(assets[1].css);
4535 assert_ne!(assets[0].url, assets[1].url);
4538 }
4539
4540 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
4544 laterite_core::testing::connect_test(&[]).await
4545 }
4546
4547 #[tokio::test]
4548 async fn builtin_migrations_create_the_admin_tables() {
4549 let (db, _guard) = empty_db().await;
4550 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
4551 .await
4552 .unwrap();
4553 for table in ["backend_users", "settings"] {
4557 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
4558 .fetch_optional(&db.pool)
4559 .await;
4560 assert!(
4561 probe.is_ok(),
4562 "{table} should exist after builtin_migrations"
4563 );
4564 }
4565 }
4566
4567 #[tokio::test]
4568 async fn brand_setting_overrides_config_and_blank_falls_back() {
4569 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
4570 let state = AdminState {
4571 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
4572 db: db.clone(),
4573 nav: Arc::new(nav::NavTree::default()),
4574 settings: Arc::new(Vec::new()),
4575 permissions: Arc::new(builtin_permissions()),
4576 trusted_proxies: Arc::new(Vec::new()),
4577 icons: Arc::new(laterite_core::icons::IconSet::new()),
4578 sprite_url: Arc::from(""),
4579 admin_path: Arc::from("/admin"),
4580 cookie_path: Arc::from("/admin"),
4581 secure_cookie: false,
4582 origin: Arc::from(""),
4583 timezone: Tz::UTC,
4584 default_locale: Arc::from("en"),
4585 catalogs: Arc::new(CatalogStore::default()),
4586 app_name: "Configured Name".to_string(),
4587 brand_cache: Arc::new(RwLock::new(None)),
4588 field_types: Arc::new(field::builtin_registry()),
4589 column_types: Arc::new(list::builtin_column_registry()),
4590 plugin_defined: Arc::new(laterite_core::Registry::new()),
4591 assets: Arc::new(builtin_assets()),
4592 asset_urls: Arc::new(asset_urls(&builtin_assets())),
4593 pickers: Arc::new(picker::PickerRegistry::new()),
4594 palette: Arc::new(Vec::new()),
4595 overrides: Arc::new(field::NoOverrides),
4596 };
4597
4598 assert_eq!(state.brand().await, "Configured Name");
4600
4601 settings::store::save(
4604 &db,
4605 &settings::BrandSetting {
4606 app_name: "Acme Corp".to_string(),
4607 },
4608 )
4609 .await
4610 .unwrap();
4611 state.invalidate_brand();
4612 assert_eq!(state.brand().await, "Acme Corp");
4613
4614 settings::store::save(
4616 &db,
4617 &settings::BrandSetting {
4618 app_name: " ".to_string(),
4619 },
4620 )
4621 .await
4622 .unwrap();
4623 state.invalidate_brand();
4624 assert_eq!(state.brand().await, "Configured Name");
4625 }
4626
4627 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
4628 settings::SettingsItem {
4629 code: code.to_string(),
4630 label: code.into(),
4631 description: String::new().into(),
4632 category: "General".into(),
4633 hint: None,
4634 confirm_leave: true,
4635 order: 1,
4636 icon: None,
4637 permission: permission.map(str::to_string),
4638 link: None,
4639 fields: Vec::new(),
4640 route: None,
4641 per_operator: false,
4642 }
4643 }
4644
4645 #[test]
4646 fn settings_visibility_respects_permissions() {
4647 let items = vec![
4648 settings_item("public", None),
4649 settings_item("gated", Some("backend.manage_users")),
4650 ];
4651
4652 let none = PermissionSet::new(false, Vec::<String>::new());
4654 let codes: Vec<String> = visible_settings(&items, &none)
4655 .into_iter()
4656 .map(|i| i.code)
4657 .collect();
4658 assert_eq!(codes, ["public"]);
4659
4660 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
4662 assert_eq!(visible_settings(&items, &granted).len(), 2);
4663
4664 let superuser = PermissionSet::new(true, Vec::<String>::new());
4666 assert_eq!(visible_settings(&items, &superuser).len(), 2);
4667 }
4668
4669 #[test]
4670 fn context_sidebar_follows_settings_links() {
4671 let items = builtin_settings();
4672 let superuser = PermissionSet::new(true, Vec::<String>::new());
4673 let tr = Translator::new("en");
4674 let tree = nav::NavTree::default();
4675 let sidebar = |path: &str| {
4676 resolve_nav_context(
4677 &tree,
4678 &items,
4679 "/admin",
4680 &superuser,
4681 None,
4682 path,
4683 &tr,
4684 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
4685 )
4686 .1
4687 };
4688 let active_path = |path: &str| -> Option<String> {
4689 sidebar(path)
4690 .into_iter()
4691 .flat_map(|g| g.items)
4692 .find(|i| i.active)
4693 .map(|i| i.path)
4694 };
4695
4696 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
4698 assert_eq!(
4699 active_path("/admin/roles/42/edit").as_deref(),
4700 Some("/admin/roles")
4701 );
4702 assert!(!sidebar("/admin/settings").is_empty());
4704 assert_eq!(active_path("/admin/settings"), None);
4705 assert_eq!(
4710 active_path("/admin/settings/branding").as_deref(),
4711 Some("/admin/settings/branding")
4712 );
4713 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
4716 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
4717 assert!(sidebar("/admin").is_empty());
4719 }
4720}
4721
4722#[cfg(test)]
4723mod session_cookie_tests {
4724 use super::*;
4725
4726 #[test]
4730 fn the_session_cookie_never_outlives_the_browser() {
4731 let cookie = session_cookie("tok".into(), "/admin", false);
4732 assert_eq!(cookie.max_age(), None);
4733 assert!(cookie.http_only().unwrap_or(false));
4734 assert_eq!(cookie.path(), Some("/admin"));
4735 }
4736
4737 #[test]
4740 fn the_remember_cookie_carries_its_own_lifetime() {
4741 let credential = laterite_auth::RememberCredential {
4742 cookie: "sel:ver".into(),
4743 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
4744 };
4745 let cookie = remember_cookie(credential, "/admin", true);
4746 let age = cookie.max_age().expect("a remember cookie must persist");
4747 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
4749 assert!(cookie.http_only().unwrap_or(false));
4750 assert!(cookie.secure().unwrap_or(false));
4751 }
4752
4753 #[test]
4755 fn the_removal_matches_the_cookie_it_clears() {
4756 let removal = remember_removal("/admin");
4757 assert_eq!(removal.name(), REMEMBER_COOKIE);
4758 assert_eq!(removal.path(), Some("/admin"));
4759 }
4760}