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 secure_cookie: bool,
202 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
204 icons: Arc<laterite_core::icons::IconSet>,
206 sprite_url: Arc<str>,
208 origin: Arc<str>,
210 timezone: Tz,
211 default_locale: Arc<str>,
215 catalogs: Arc<CatalogStore>,
218 app_name: String,
221 brand_cache: Arc<RwLock<Option<String>>>,
225 asset_urls: Arc<AssetUrls>,
227 field_types: Arc<field::FieldRegistry>,
230 column_types: Arc<list::ColumnRegistry>,
233 plugin_defined: Arc<laterite_core::Registry>,
236 assets: Arc<AssetRegistry>,
238 pickers: Arc<picker::PickerRegistry>,
241 palette: Arc<Vec<palette::RecordSource>>,
244 overrides: Arc<dyn field::OverrideResolver>,
248}
249
250impl AdminState {
251 #[cfg(test)]
252 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
253 Self {
254 auth,
255 db,
256 nav: Arc::new(nav::NavTree::default()),
257 settings: Arc::new(Vec::new()),
258 permissions: Arc::new(builtin_permissions()),
259 trusted_proxies: Arc::new(Vec::new()),
260 icons: Arc::new(laterite_core::icons::IconSet::new()),
261 sprite_url: Arc::from(""),
262 admin_path: Arc::from("/admin"),
263 secure_cookie: false,
264 origin: Arc::from(""),
265 timezone: Tz::UTC,
266 default_locale: Arc::from("en"),
267 catalogs: Arc::new(CatalogStore::default()),
268 app_name: "Laterite".to_string(),
269 brand_cache: Arc::new(RwLock::new(None)),
270 field_types: Arc::new(field::builtin_registry()),
271 column_types: Arc::new(list::builtin_column_registry()),
272 plugin_defined: Arc::new(laterite_core::Registry::new()),
273 assets: Arc::new(builtin_assets()),
274 asset_urls: Arc::new(asset_urls(&builtin_assets())),
275 pickers: Arc::new(picker::PickerRegistry::new()),
276 palette: Arc::new(Vec::new()),
277 overrides: Arc::new(field::NoOverrides),
278 }
279 }
280
281 async fn brand(&self) -> String {
285 {
286 let cached = self.brand_cache.read().unwrap().clone();
287 if let Some(name) = cached {
288 return name;
289 }
290 }
291 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
292 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
293 _ => self.app_name.clone(),
294 };
295 *self.brand_cache.write().unwrap() = Some(resolved.clone());
296 resolved
297 }
298
299 fn invalidate_brand(&self) {
301 *self.brand_cache.write().unwrap() = None;
302 }
303}
304
305#[derive(Clone)]
311#[non_exhaustive]
312pub struct AdminConfig {
313 pub secure_cookie: bool,
316 pub trusted_proxies: Vec<String>,
320 pub timezone: String,
324 pub locale: String,
328 pub app_name: String,
332 pub path: String,
336 pub origin: String,
341 pub storage_dir: String,
346}
347
348impl Default for AdminConfig {
349 fn default() -> Self {
350 Self {
351 secure_cookie: false,
352 trusted_proxies: Vec::new(),
353 timezone: "UTC".to_string(),
354 locale: "en".to_string(),
355 app_name: "Laterite".to_string(),
356 path: "/admin".to_string(),
357 origin: String::new(),
358 storage_dir: "storage".to_string(),
359 }
360 }
361}
362
363#[derive(Clone)]
372pub struct Shell {
373 pub(crate) base: String,
377 brand: String,
381 nav: Vec<NavView>,
382 full_name: String,
383 initial: String,
384 tz: Tz,
388 i18n: Translator,
392 sidebar: Vec<settings::CategoryView>,
396 crumbs: Vec<CrumbView>,
399 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
401 pub(crate) sprite_url: Arc<str>,
404 site_url: String,
408 asset_urls: Arc<AssetUrls>,
412 pub(crate) csrf_token: String,
416 pub(crate) flash: Vec<FlashLine>,
420 pub(crate) assets: Vec<PageAsset>,
424}
425
426#[derive(Template)]
428#[template(path = "page.html")]
429struct PageTemplate {
430 title: String,
431 body: html::Markup,
432 shell: Shell,
433}
434
435#[derive(Clone)]
437pub(crate) struct PageAsset {
438 pub(crate) url: String,
439 pub(crate) css: bool,
440}
441
442#[derive(Clone)]
445pub(crate) struct FlashLine {
446 pub(crate) level: session::FlashLevel,
447 pub(crate) text: String,
448 pub(crate) sticky: bool,
450}
451
452impl Shell {
453 #[allow(clippy::too_many_arguments)]
454 fn new(
455 base: &str,
456 brand: String,
457 nav: &[nav::NavNode],
458 user: &AuthenticatedUser,
459 default_tz: Tz,
460 sidebar: Vec<settings::CategoryView>,
461 active_nav: Option<&str>,
462 csrf_token: String,
463 flash: Vec<session::Flash>,
464 i18n: Translator,
465 asset_urls: Arc<AssetUrls>,
466 site_url: String,
467 icon_set: Arc<laterite_core::icons::IconSet>,
468 sprite_url: Arc<str>,
469 ) -> Self {
470 let full_name = user.user.full_name();
471 let initial = full_name
472 .chars()
473 .next()
474 .map(|c| c.to_uppercase().to_string())
475 .unwrap_or_else(|| "?".to_string());
476 let flash = flash
479 .into_iter()
480 .map(|f| FlashLine {
481 sticky: f.sticky,
482 level: f.level,
483 text: i18n.t(&f.text),
484 })
485 .collect();
486 let nav: Vec<NavView> = nav
487 .iter()
488 .map(|n| NavView {
489 key: n.item.key.clone(),
490 label: i18n.t(&n.item.label),
491 path: nav::tab_path(n).unwrap_or_default().to_string(),
492 active: active_nav == Some(n.item.key.as_str()),
493 icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
494 })
495 .collect();
496 let mut crumbs: Vec<CrumbView> = nav
497 .iter()
498 .filter(|n: &&NavView| n.active)
499 .map(|n| CrumbView {
500 label: n.label.clone(),
501 href: n.path.clone(),
502 })
503 .collect();
504 if let Some(entry) = sidebar
505 .iter()
506 .flat_map(|g| g.items.iter())
507 .find(|i| i.active)
508 {
509 crumbs.push(CrumbView {
510 label: entry.label.clone(),
511 href: entry.path.clone(),
512 });
513 }
514 Shell {
515 asset_urls,
516 icon_set,
517 sprite_url,
518 site_url,
519 base: base.to_string(),
520 brand,
521 nav,
522 crumbs,
523 full_name,
524 initial,
525 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
526 i18n,
527 sidebar,
528 csrf_token,
529 flash,
530 assets: Vec::new(),
531 }
532 }
533
534 pub(crate) fn site_url(&self) -> &str {
536 &self.site_url
537 }
538
539 pub(crate) fn sidebar_anchored(&self) -> bool {
543 self.nav.iter().any(|n| n.active)
544 }
545
546 pub(crate) fn icon(&self, name: &str) -> String {
547 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
548 }
549
550 pub(crate) fn asset(&self, key: &str) -> String {
555 match self.asset_urls.get(key) {
556 Some(path) => format!("{}/assets/{path}", self.base),
557 None => {
558 debug_assert!(false, "asset key `{key}` is not registered");
559 format!("{}/assets/{key}", self.base)
560 }
561 }
562 }
563
564 pub fn t(&self, source: &str) -> String {
567 self.i18n.t(&Text::dynamic(source))
568 }
569
570 pub fn tt(&self, text: &Text) -> String {
573 self.i18n.t(text)
574 }
575
576 pub fn i18n(&self) -> &Translator {
579 &self.i18n
580 }
581
582 pub fn locale(&self) -> &str {
584 self.i18n.locale()
585 }
586
587 pub fn base(&self) -> &str {
589 &self.base
590 }
591
592 pub fn csrf_token(&self) -> &str {
596 &self.csrf_token
597 }
598
599 pub fn datetime(&self, utc: &str) -> String {
602 match chrono::DateTime::parse_from_rfc3339(utc) {
603 Ok(at) => at
604 .with_timezone(&self.tz)
605 .format("%Y-%m-%d %H:%M")
606 .to_string(),
607 Err(_) => utc.to_string(),
608 }
609 }
610
611 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
615 render(PageTemplate {
616 title: self.tt(title),
617 body,
618 shell: self.clone(),
619 })
620 }
621
622 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
626 let mut text = Text::dynamic(source);
627 for (name, value) in args {
628 text = text.arg(*name, *value);
629 }
630 self.i18n.t(&text)
631 }
632
633 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
636 let mut text = Text::dynamic(source);
637 for (name, value) in args {
638 text = text.arg(*name, *value);
639 }
640 self.i18n.t(&text)
641 }
642
643 #[cfg(test)]
644 pub(crate) fn test() -> Self {
645 Shell {
646 asset_urls: Arc::new(asset_urls(&builtin_assets())),
647 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
648 sprite_url: Arc::from("/admin/assets/icons.svg"),
649 site_url: "http://localhost".to_string(),
650 base: "/admin".to_string(),
651 brand: "Laterite".to_string(),
652 nav: Vec::new(),
653 full_name: "Test Operator".to_string(),
654 initial: "T".to_string(),
655 tz: Tz::UTC,
656 i18n: Translator::new("en"),
657 sidebar: Vec::new(),
658 crumbs: Vec::new(),
659 csrf_token: "test-csrf-token".to_string(),
660 flash: Vec::new(),
661 assets: Vec::new(),
662 }
663 }
664}
665
666fn settings_context(
673 visible: &[settings::SettingsItem],
674 admin_path: &str,
675 path: &str,
676) -> (bool, Option<String>) {
677 if path == format!("{admin_path}/settings") {
678 return (true, None);
679 }
680 let own = visible
686 .iter()
687 .filter(|i| i.link.is_none())
688 .find(|i| path == format!("{admin_path}{}", i.route()))
689 .map(|i| i.code.clone());
690 if own.is_some() {
691 return (true, own);
692 }
693 let linked = visible
694 .iter()
695 .filter_map(|i| {
696 i.link
697 .as_deref()
698 .map(|link| (format!("{admin_path}{link}"), &i.code))
699 })
700 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
701 .max_by_key(|(link, _)| link.len())
702 .map(|(_, code)| code.clone());
703 (linked.is_some(), linked)
704}
705
706#[allow(clippy::too_many_arguments)]
712fn resolve_nav_context(
713 nav: &nav::NavTree,
714 items: &[settings::SettingsItem],
715 admin_path: &str,
716 perms: &PermissionSet,
717 own_order: Option<&str>,
718 path: &str,
719 tr: &Translator,
720 icons: icons::Icons<'_>,
721) -> (
722 Vec<nav::NavNode>,
723 Vec<settings::CategoryView>,
724 Option<String>,
725) {
726 let mut visible_nav = nav.visible(&|code| perms.allows(code));
727 if let Some(order) = own_order {
728 nav::apply_order(&mut visible_nav, order);
729 }
730 let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
732 .into_iter()
733 .filter(|i| !i.per_operator)
734 .collect();
735 let (in_context, active) = settings_context(&visible, admin_path, path);
736 if in_context {
737 let sidebar = settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons);
738 return (visible_nav, sidebar, Some("settings".to_string()));
739 }
740 let active_top = nav::active_top(&visible_nav, admin_path, path);
741 let sidebar = active_top
742 .map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
743 .unwrap_or_default();
744 let active_key = active_top.map(|node| node.item.key.clone());
745 (visible_nav, sidebar, active_key)
746}
747
748fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
752 preference
753 .and_then(|name| name.parse::<Tz>().ok())
754 .unwrap_or(default_tz)
755}
756
757fn zone_offered(name: &str) -> bool {
759 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
760}
761
762fn base_lang(tag: &str) -> String {
765 tag.split(['-', '_'])
766 .next()
767 .unwrap_or(tag)
768 .to_ascii_lowercase()
769}
770
771fn is_serveable(base: &str, serveable: &[String]) -> bool {
777 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
778}
779
780fn default_locale(configured: &str, serveable: &[String]) -> String {
783 let base = base_lang(configured);
784 if is_serveable(&base, serveable) {
785 base
786 } else {
787 "en".to_string()
788 }
789}
790
791fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
795 let mut out = vec!["en".to_string()];
796 out.extend(catalogs.locales());
797 out
798}
799
800fn locale_name(code: &str) -> String {
805 if code == "en" {
806 "English".to_string()
807 } else {
808 code.to_string()
809 }
810}
811
812fn parse_accept_language(header: &str) -> Vec<String> {
815 let mut items: Vec<(f32, String)> = header
816 .split(',')
817 .filter_map(|part| {
818 let mut bits = part.split(';');
819 let tag = bits.next()?.trim();
820 if tag.is_empty() || tag == "*" {
821 return None;
822 }
823 let q = bits
824 .find_map(|p| p.trim().strip_prefix("q="))
825 .and_then(|q| q.parse::<f32>().ok())
826 .unwrap_or(1.0);
827 Some((q, tag.to_string()))
828 })
829 .collect();
830 items.sort_by(|a, b| b.0.total_cmp(&a.0));
832 items.into_iter().map(|(_, tag)| tag).collect()
833}
834
835fn resolve_locale_chain(
841 preference: Option<&str>,
842 accept_language: Option<&str>,
843 default: &str,
844 serveable: &[String],
845) -> Vec<String> {
846 let mut chain: Vec<String> = Vec::new();
847 let consider = |tag: &str, chain: &mut Vec<String>| {
848 let base = base_lang(tag);
849 if is_serveable(&base, serveable) && !chain.contains(&base) {
850 chain.push(base);
851 }
852 };
853 if let Some(pref) = preference {
854 consider(pref, &mut chain);
855 }
856 if let Some(header) = accept_language {
857 for tag in parse_accept_language(header) {
858 consider(&tag, &mut chain);
859 }
860 }
861 consider(default, &mut chain);
862 consider("en", &mut chain);
865 chain
866}
867
868fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
872 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
873 let serveable = state.catalogs.locales();
874 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
875 Translator::with_chain(chain, state.catalogs.clone())
876}
877
878#[derive(Serialize)]
886#[non_exhaustive]
887pub struct Resource {
888 pub base_path: String,
894 pub nav_label: Text,
896 pub list: list::ListConfig,
897 pub form: Option<form::FormConfig>,
898 pub permission: Option<String>,
905 pub import: Option<ImportConfig>,
908 pub palette: Option<String>,
911 pub relations: Vec<relation::RelationConfig>,
914 pub menu: nav::Placement,
917}
918
919#[derive(Clone, Debug, Default, Serialize, Deserialize)]
922#[non_exhaustive]
923pub struct ImportConfig {
924 #[serde(default)]
927 pub permission: Option<String>,
928}
929
930impl ImportConfig {
931 pub fn new() -> Self {
932 Self::default()
933 }
934
935 pub fn permission(mut self, code: impl Into<String>) -> Self {
938 self.permission = Some(code.into());
939 self
940 }
941}
942
943#[derive(Clone, Serialize)]
948#[non_exhaustive]
949pub struct Permission {
950 pub code: String,
951 pub label: Text,
953 pub group: Text,
954 pub default_roles: Vec<String>,
957 pub renamed_from: Vec<String>,
960}
961
962pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
963
964impl Permission {
965 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
968 Self {
969 code: code.into(),
970 label: label.into(),
971 group: group.into(),
972 default_roles: Vec::new(),
973 renamed_from: Vec::new(),
974 }
975 }
976
977 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
983 where
984 I: IntoIterator<Item = S>,
985 S: Into<String>,
986 {
987 self.renamed_from = codes.into_iter().map(Into::into).collect();
988 self
989 }
990
991 pub fn roles<I, S>(mut self, roles: I) -> Self
994 where
995 I: IntoIterator<Item = S>,
996 S: Into<String>,
997 {
998 self.default_roles = roles.into_iter().map(Into::into).collect();
999 self
1000 }
1001}
1002
1003pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
1009 let permissions: Vec<Permission> = builtin_permissions()
1010 .into_iter()
1011 .chain(contributed.iter().cloned())
1012 .collect();
1013 let mut out: Vec<(String, String)> = Vec::new();
1014 for permission in &permissions {
1015 for old in &permission.renamed_from {
1016 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
1017 panic!(
1018 "permissions `{taken}` and `{}` both claim the old code `{old}`",
1019 permission.code
1020 );
1021 }
1022 if permissions.iter().any(|p| &p.code == old) {
1023 panic!(
1024 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
1025 permission.code
1026 );
1027 }
1028 out.push((old.clone(), permission.code.clone()));
1029 }
1030 }
1031 out
1032}
1033
1034pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
1040 let permissions: Vec<Permission> = builtin_permissions()
1043 .into_iter()
1044 .chain(contributed.iter().cloned())
1045 .collect();
1046 let held = |role: &str| -> Vec<String> {
1047 permissions
1048 .iter()
1049 .filter(|p| {
1050 if p.default_roles.is_empty() {
1051 role == ROLE_ADMIN
1052 } else {
1053 p.default_roles.iter().any(|r| r == role)
1054 }
1055 })
1056 .map(|p| p.code.clone())
1057 .collect()
1058 };
1059 vec![
1060 laterite_auth::store::SystemRole {
1061 code: ROLE_ADMIN,
1062 name: "Administrator",
1063 description: "Administers the panel itself: operators, roles and settings.",
1064 permissions: held(ROLE_ADMIN),
1065 },
1066 laterite_auth::store::SystemRole {
1067 code: ROLE_EDITOR,
1068 name: "Editor",
1069 description: "Works with the content the application declares.",
1070 permissions: held(ROLE_EDITOR),
1071 },
1072 ]
1073}
1074
1075impl Resource {
1076 pub fn new(
1081 base_path: impl Into<String>,
1082 nav_label: impl Into<Text>,
1083 list: list::ListConfig,
1084 ) -> Self {
1085 Self {
1086 base_path: base_path.into(),
1087 nav_label: nav_label.into(),
1088 list,
1089 form: None,
1090 permission: None,
1091 import: None,
1092 palette: None,
1093 relations: Vec::new(),
1094 menu: nav::Placement::default(),
1095 }
1096 }
1097
1098 pub fn palette(mut self, source: impl Into<String>) -> Self {
1101 self.palette = Some(source.into());
1102 self
1103 }
1104
1105 pub fn menu(mut self, placement: nav::Placement) -> Self {
1107 self.menu = placement;
1108 self
1109 }
1110
1111 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1113 self.relations.push(relation);
1114 self
1115 }
1116
1117 pub fn import(mut self, import: ImportConfig) -> Self {
1119 self.import = Some(import);
1120 self
1121 }
1122
1123 pub fn form(mut self, form: form::FormConfig) -> Self {
1126 self.form = Some(form);
1127 self
1128 }
1129
1130 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1132 self.permission = Some(permission.into());
1133 self
1134 }
1135}
1136
1137struct SystemRoleGuard;
1143
1144#[laterite_core::strata::async_trait]
1145impl laterite_core::ModelListener for SystemRoleGuard {
1146 async fn before_delete(
1147 &self,
1148 cx: &mut laterite_core::SaveCx<'_>,
1149 rec: &laterite_core::Record,
1150 ) -> Result<(), Text> {
1151 let Some(id) = rec.id() else {
1152 return Ok(());
1153 };
1154 let backend = cx.backend();
1156 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1157 Ok(true) => Err(t!(
1158 "The framework maintains this role; it cannot be deleted."
1159 )),
1160 Err(_) => Err(t!(
1162 "Could not check whether this role is maintained by the framework."
1163 )),
1164 Ok(false) => Ok(()),
1165 }
1166 }
1167}
1168
1169fn builtin_permissions() -> Vec<Permission> {
1172 vec![
1175 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1176 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1177 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1178 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1179 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1180 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1181 Permission::new(
1182 "backend.view_request_log",
1183 "View the request log",
1184 "Backend",
1185 ),
1186 ]
1187}
1188
1189pub fn descriptor_sources() -> Vec<String> {
1195 let mut out = Vec::new();
1196 out.extend(laterite_core::collect_sources(&builtin_resources()));
1197 out.extend(laterite_core::collect_sources(&builtin_settings()));
1198 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1199 out.sort();
1200 out.dedup();
1201 out
1202}
1203
1204pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1219 builtin_modules().iter().map(|m| m.migrations()).collect()
1220}
1221
1222pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1226 vec![
1227 Box::new(laterite_auth::AuthModule),
1228 Box::new(settings::SettingsModule),
1229 Box::new(requestlog::RequestLogModule),
1230 Box::new(plugins::PluginsModule),
1231 Box::new(laterite_media::MediaModule),
1232 Box::new(import::ImportModule),
1233 ]
1234}
1235
1236#[derive(Default)]
1260pub struct Contributions {
1261 pub resources: Vec<Resource>,
1263 pub settings: Vec<settings::SettingsItem>,
1265 pub permissions: Vec<Permission>,
1267 pub picker_sources: Vec<picker::PickerSourceReg>,
1269 pub persisters: Vec<persist::PersisterReg>,
1271 pub listeners: Vec<laterite_core::ModelListenerReg>,
1273 pub field_types: Vec<field::FieldTypeReg>,
1277 pub column_types: Vec<list::ColumnTypeReg>,
1278 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1281 pub screens: Vec<routes::ScreenReg>,
1283 pub public_routes: Vec<routes::PublicRouteReg>,
1285 pub nav: Vec<nav::NavItem>,
1288 pub resource_hooks: Vec<ResourceHook>,
1291 pub plugin_defined: Arc<laterite_core::Registry>,
1296}
1297
1298pub fn router(
1299 auth: AuthService,
1300 db: Db,
1301 contributions: Contributions,
1302 config: AdminConfig,
1303 catalogs: Arc<CatalogStore>,
1304) -> Router {
1305 let Contributions {
1306 resources: app_resources,
1307 settings: app_settings,
1308 permissions: app_permissions,
1309 picker_sources: app_picker_sources,
1310 persisters: app_persisters,
1311 listeners: app_listeners,
1312 field_types: app_field_types,
1313 column_types: app_column_types,
1314 icons: app_icons,
1315 screens: app_screens,
1316 public_routes: app_public,
1317 nav: app_nav,
1318 resource_hooks,
1319 plugin_defined,
1320 } = contributions;
1321 let admin_path = normalize_path(&config.path);
1322
1323 let mut resources = builtin_resources();
1324 let mut app_resources = app_resources;
1325 for hook in resource_hooks {
1328 let found = resources
1329 .iter_mut()
1330 .chain(app_resources.iter_mut())
1331 .find(|r| r.base_path == hook.target);
1332 match found {
1333 Some(resource) => {
1334 let changed = hook.apply(std::mem::replace(
1335 resource,
1336 Resource::new("", "", list::ListConfig::default()),
1337 ));
1338 *resource = changed;
1339 }
1340 None => panic!(
1341 "a resource hook targets `{}`, which no module declared",
1342 hook.target
1343 ),
1344 }
1345 }
1346 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1350 prefix_resource(&admin_path, resource);
1351 }
1352 for resource in app_resources.iter_mut() {
1356 if let Some(import) = &resource.import {
1357 let permission = import
1358 .permission
1359 .clone()
1360 .or_else(|| resource.permission.clone())
1361 .unwrap_or_else(|| {
1362 panic!(
1363 "admin resource `{}` declares `import` but no permission; importing \
1364 writes records, so it must be gated",
1365 resource.base_path
1366 )
1367 });
1368 resource.list.import = Some(routes::feature_link(
1369 &resource.base_path,
1370 "import",
1371 &permission,
1372 ));
1373 }
1374 }
1375
1376 let mut settings = builtin_settings();
1377 settings.extend(app_settings);
1378 let mut app_listeners = app_listeners;
1381 app_listeners.insert(
1382 0,
1383 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1384 );
1385
1386 let mut permissions = builtin_permissions();
1387 permissions.extend(app_permissions);
1388 let permission_codes: std::collections::HashSet<String> =
1390 permissions.iter().map(|p| p.code.clone()).collect();
1391
1392 let mut nav_items = vec![
1398 nav::NavItem::new("dashboard", "Dashboard", "")
1399 .icon("layout-dashboard")
1400 .order(i32::MIN),
1401 nav::NavItem::new("settings", "Settings", "/settings")
1402 .icon("settings")
1403 .order(i32::MAX),
1404 ];
1405 nav_items.extend(app_nav);
1406 for resource in &app_resources {
1407 if resource.menu.hidden {
1408 continue;
1409 }
1410 let unprefixed = resource
1411 .base_path
1412 .strip_prefix(admin_path.as_str())
1413 .unwrap_or(&resource.base_path)
1414 .to_string();
1415 let mut item = nav::NavItem::new(
1416 format!("resource:{unprefixed}"),
1417 resource.nav_label.clone(),
1418 unprefixed,
1419 )
1420 .order(resource.menu.order);
1421 item.icon = resource.menu.icon.clone();
1422 item.permission = resource.permission.clone();
1423 item.under = resource.menu.under.clone();
1424 item.after = resource.menu.after.clone();
1425 item.before = resource.menu.before.clone();
1426 nav_items.push(item);
1427 }
1428 for reg in &app_screens {
1429 let Some(label) = ®.nav_label else {
1430 continue;
1431 };
1432 if reg.menu.hidden {
1433 continue;
1434 }
1435 let mut item = nav::NavItem::new(
1436 format!("screen:{}", reg.base_path),
1437 label.clone(),
1438 reg.base_path.clone(),
1439 )
1440 .permission(reg.permission.clone())
1441 .order(reg.menu.order);
1442 item.icon = reg.menu.icon.clone();
1443 item.under = reg.menu.under.clone();
1444 item.after = reg.menu.after.clone();
1445 item.before = reg.menu.before.clone();
1446 nav_items.push(item);
1447 }
1448 let nav =
1449 nav::NavTree::build(nav_items, &admin_path).unwrap_or_else(|e| panic!("admin menu: {e}"));
1450 let palette_sources: Vec<palette::RecordSource> = app_resources
1453 .iter()
1454 .filter_map(|r| {
1455 r.palette.as_ref().map(|source| palette::RecordSource {
1456 label: r.nav_label.clone(),
1457 source: source.clone(),
1458 base_path: r.base_path.clone(),
1459 permission: r.permission.clone(),
1460 })
1461 })
1462 .collect();
1463 resources.extend(app_resources);
1464
1465 let app_name = if config.app_name.trim().is_empty() {
1466 "Laterite".to_string()
1467 } else {
1468 config.app_name.clone()
1469 };
1470
1471 let mut pickers = picker::PickerRegistry::new();
1474 for reg in app_picker_sources {
1475 let name = reg.name.clone();
1476 if !field::is_name(&name, true) {
1477 panic!("invalid picker source name `{name}`");
1478 }
1479 if pickers.insert(name.clone(), reg).is_some() {
1480 panic!("duplicate picker source `{name}`");
1481 }
1482 }
1483
1484 for source in &palette_sources {
1488 if !pickers.contains_key(&source.source) {
1489 panic!(
1490 "admin resource `{}` names palette source `{}`, which no module registered",
1491 source.base_path, source.source
1492 );
1493 }
1494 }
1495 let pickers = Arc::new(pickers);
1496 let mut field_types = field::builtin_registry();
1497 field_types.insert(
1498 "reference".to_string(),
1499 Arc::new(field::RefPickerField::new(pickers.clone())),
1500 );
1501
1502 let mut persisters = persist::PersisterRegistry::new();
1505 for reg in app_persisters {
1506 let name = reg.name.clone();
1507 if !field::is_name(&name, true) {
1508 panic!("invalid persister name `{name}`");
1509 }
1510 if persisters.insert(name.clone(), reg.persister).is_some() {
1511 panic!("duplicate persister `{name}`");
1512 }
1513 }
1514
1515 for reg in app_field_types {
1521 let name = reg.name().to_string();
1522 if !field::is_name(&name, true) {
1523 panic!("invalid field type name `{name}`");
1524 }
1525 if field_types.insert(name.clone(), reg.field_type).is_some() {
1526 panic!("duplicate field type `{name}`");
1527 }
1528 }
1529
1530 let mut icon_set = laterite_core::icons::IconSet::new();
1534 for (owner, reg) in app_icons {
1535 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1536 panic!("module `{owner}` contributed an invalid icon: {e}");
1537 }
1538 }
1539
1540 let mut assets = builtin_assets();
1543 assets.insert(
1544 ICON_SPRITE,
1545 AdminAsset {
1546 mime: "image/svg+xml",
1547 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1548 },
1549 );
1550
1551 for item in &settings {
1555 icons::require(
1556 &icon_set,
1557 &format!("settings item `{}`", item.code),
1558 item.icon.as_deref(),
1559 );
1560 }
1561 for node in nav
1562 .top
1563 .iter()
1564 .flat_map(|n| std::iter::once(n).chain(n.children.iter()))
1565 {
1566 icons::require(
1567 &icon_set,
1568 &format!("menu entry `{}`", node.item.key),
1569 node.item.icon.as_deref(),
1570 );
1571 }
1572
1573 let mut column_types = list::builtin_column_registry();
1577 for reg in app_column_types {
1578 let name = reg.name().to_string();
1579 if !field::is_name(&name, true) {
1580 panic!("invalid column type name `{name}`");
1581 }
1582 if column_types.insert(name.clone(), reg.column_type).is_some() {
1583 panic!("duplicate column type `{name}`");
1584 }
1585 }
1586
1587 let settings = Arc::new(settings);
1588 let asset_urls_arc = Arc::new(asset_urls(&assets));
1590 let sprite_url: Arc<str> = Arc::from(
1591 asset_urls_arc
1592 .get(ICON_SPRITE)
1593 .map(|p| format!("{admin_path}/assets/{p}"))
1594 .unwrap_or_default()
1595 .as_str(),
1596 );
1597 let icons_arc = Arc::new(icon_set);
1598 let state = AdminState {
1599 auth,
1600 db,
1601 nav: Arc::new(nav),
1602 settings: settings.clone(),
1603 permissions: Arc::new(permissions),
1604 admin_path: Arc::from(admin_path.as_str()),
1605 secure_cookie: config.secure_cookie,
1606 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1607 icons: icons_arc.clone(),
1608 sprite_url: sprite_url.clone(),
1609 origin: Arc::from(config.origin.trim_end_matches('/')),
1610 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1611 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1612 catalogs,
1613 app_name,
1614 brand_cache: Arc::new(RwLock::new(None)),
1615 field_types: Arc::new(field_types),
1616 column_types: Arc::new(column_types),
1617 plugin_defined,
1618 assets: Arc::new(assets),
1619 asset_urls: asset_urls_arc.clone(),
1620 pickers,
1621 palette: Arc::new(palette_sources),
1622 overrides: Arc::new(field::NoOverrides),
1623 };
1624
1625 let mut protected = Router::new().route(&admin_path, get(dashboard));
1626 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
1630 for resource in &resources {
1631 let (mounted, prepared) = mount_resource(
1632 resource,
1633 &state.field_types,
1634 &state.column_types,
1635 &permission_codes,
1636 &persisters,
1637 &app_listeners,
1638 );
1639 protected = protected.merge(mounted);
1640 if let Some(prepared) = prepared {
1641 let key = resource
1642 .base_path
1643 .strip_prefix(admin_path.as_str())
1644 .unwrap_or(&resource.base_path);
1645 forms.insert(key.to_string(), prepared);
1646 }
1647 }
1648 let forms: form::Forms = Arc::new(forms);
1649 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
1653 .plugin_defined
1654 .items::<laterite_media::DiskReg>()
1655 .into_iter()
1656 .find(|d| d.name == import::DISK)
1657 .map(|d| d.driver.clone())
1658 .unwrap_or_else(|| {
1659 Arc::new(laterite_media::LocalDisk::new(format!(
1660 "{}/{}",
1661 config.storage_dir.trim_end_matches('/'),
1662 import::DISK
1663 )))
1664 });
1665 for resource in &resources {
1666 let Some(link) = &resource.list.import else {
1667 continue;
1668 };
1669 let key = resource
1670 .base_path
1671 .strip_prefix(admin_path.as_str())
1672 .unwrap_or(&resource.base_path)
1673 .to_string();
1674 let prepared = forms.get(&key).unwrap_or_else(|| {
1675 panic!(
1676 "admin resource `{}` declares `import` but has no form to import through",
1677 resource.base_path
1678 )
1679 });
1680 let feature = import::ImportFeature {
1681 disk: private_disk.clone(),
1682 };
1683 let base = link.href.clone();
1684 let ctx = routes::RouteCtx::new(
1685 state.db.clone(),
1686 state.auth.clone(),
1687 state.field_types.clone(),
1688 state.overrides.clone(),
1689 &base,
1690 &admin_path,
1691 &state.origin,
1692 state.plugin_defined.clone(),
1693 state.auth.events().clone(),
1694 forms.clone(),
1695 );
1696 let info = routes::ResourceInfo::new(
1697 key,
1698 resource.base_path.clone(),
1699 resource.nav_label.clone(),
1700 link.permission.clone(),
1701 form::FormHandle::new(state.db.clone(), prepared.clone()),
1702 );
1703 protected = protected.merge(guard_with_permission(
1704 Router::new()
1705 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
1706 &link.permission,
1707 ));
1708 }
1709 for reg in &app_screens {
1713 let base = format!("{admin_path}{}", reg.base_path);
1714 let ctx = routes::RouteCtx::new(
1715 state.db.clone(),
1716 state.auth.clone(),
1717 state.field_types.clone(),
1718 state.overrides.clone(),
1719 &base,
1720 &admin_path,
1721 &state.origin,
1722 state.plugin_defined.clone(),
1723 state.auth.events().clone(),
1724 forms.clone(),
1725 );
1726 protected = protected.merge(guard_with_permission(
1727 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1728 ®.permission,
1729 ));
1730 }
1731
1732 protected = protected.merge(guard_with_permission(
1735 Router::new()
1736 .route(
1737 &format!("{admin_path}/roles/new"),
1738 get(roles::new_form).post(roles::create),
1739 )
1740 .route(
1741 &format!("{admin_path}/roles/{{id}}/edit"),
1742 get(roles::edit_form).post(roles::update),
1743 ),
1744 "backend.manage_roles",
1745 ));
1746 protected = protected.merge(guard_with_permission(
1749 Router::new()
1750 .route(
1751 &format!("{admin_path}/users/new"),
1752 get(users::new_form).post(users::create),
1753 )
1754 .route(
1755 &format!("{admin_path}/users/{{id}}/edit"),
1756 get(users::edit_form).post(users::update),
1757 )
1758 .route(
1759 &format!("{admin_path}/users/{{id}}/active"),
1760 post(users::set_active),
1761 )
1762 .route(
1763 &format!("{admin_path}/users/{{id}}/password"),
1764 post(users::reset_password),
1765 )
1766 .route(
1767 &format!("{admin_path}/users/{{id}}/unlock"),
1768 post(users::unlock),
1769 ),
1770 "backend.manage_users",
1771 ));
1772 protected = protected.merge(guard_with_permission(
1775 Router::new()
1776 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1777 .route(
1778 &format!("{admin_path}/plugins/toggle"),
1779 post(plugins::toggle),
1780 ),
1781 plugins::MANAGE_PERMISSION,
1782 ));
1783 protected = protected
1784 .route(&format!("{admin_path}/settings"), get(settings_index))
1785 .route(
1786 &format!("{admin_path}/preferences"),
1787 get(preferences_form).post(preferences_update),
1788 )
1789 .route(
1790 &format!("{admin_path}/preferences/password"),
1791 post(password_update),
1792 )
1793 .route(
1794 &format!("{admin_path}/preferences/menu"),
1795 post(menu_order_update),
1796 )
1797 .route(&format!("{admin_path}/palette"), get(palette::palette))
1798 .route(
1799 &format!("{admin_path}/preferences/sessions/revoke"),
1800 post(session_revoke),
1801 )
1802 .route(
1803 &format!("{admin_path}/preferences/sessions/revoke-others"),
1804 post(session_revoke_others),
1805 )
1806 .route(
1809 &format!("{admin_path}/pickers/{{source}}/search"),
1810 any(picker::search),
1811 )
1812 .route(
1813 &format!("{admin_path}/pickers/{{source}}/resolve"),
1814 any(picker::resolve),
1815 )
1816 .route(&format!("{admin_path}/logout"), post(logout));
1817
1818 for item in settings.iter().filter(|i| i.link.is_none()) {
1822 let path = format!("{admin_path}{}", item.route());
1823 let code = item.code.clone();
1824 let edit_code = code.clone();
1825 protected = protected.route(
1826 &path,
1827 get(
1828 move |State(state): State<AdminState>,
1829 Extension(shell): Extension<Shell>,
1830 Extension(user): Extension<AuthenticatedUser>| {
1831 let code = edit_code.clone();
1832 async move { settings_edit(state, shell, user, code).await }
1833 },
1834 )
1835 .post(
1836 move |State(state): State<AdminState>,
1837 Extension(shell): Extension<Shell>,
1838 Extension(user): Extension<AuthenticatedUser>,
1839 Extension(session): Extension<session::SessionHandle>,
1840 Form(data): Form<HashMap<String, String>>| {
1841 let code = code.clone();
1842 async move { settings_update(state, shell, user, session, code, data).await }
1843 },
1844 ),
1845 );
1846 }
1847
1848 let app = protected
1849 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1850 .route(
1854 &format!("{admin_path}/login"),
1855 get(login_form).post(login_submit),
1856 )
1857 .route(
1858 &format!("{admin_path}/setup"),
1859 get(setup_form).post(setup_submit),
1860 )
1861 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1862 .merge(mount_public(
1866 &app_public,
1867 &state.db,
1868 &state.auth,
1869 &state.field_types,
1870 &state.overrides,
1871 &admin_path,
1872 &state.origin,
1873 &state.plugin_defined,
1874 state.auth.events(),
1875 &forms,
1876 ));
1877 let app = match app_public.iter().find(|r| r.is_site()) {
1880 Some(site) => {
1881 let ctx = routes::RouteCtx::new(
1882 state.db.clone(),
1883 state.auth.clone(),
1884 state.field_types.clone(),
1885 state.overrides.clone(),
1886 "/",
1887 &admin_path,
1888 &state.origin,
1889 state.plugin_defined.clone(),
1890 state.auth.events().clone(),
1891 forms.clone(),
1892 );
1893 app.fallback_service(site.route.mount(&ctx))
1894 }
1895 None => app.fallback(not_found_fallback),
1896 };
1897 app
1898 .layer(middleware::from_fn_with_state(
1901 state.clone(),
1902 enforce_origin,
1903 ))
1904 .layer(middleware::from_fn_with_state(
1907 state.clone(),
1908 capture_client,
1909 ))
1910 .layer(
1915 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1916 header::CACHE_CONTROL,
1917 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1918 ),
1919 )
1920 .layer(
1924 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1925 header::VARY,
1926 axum::http::HeaderValue::from_static("HX-Request"),
1927 ),
1928 )
1929 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1930 handle_panic,
1931 ))
1932 .with_state(state)
1933}
1934
1935async fn not_found_fallback(
1938 State(state): State<AdminState>,
1939 method: axum::http::Method,
1940 uri: axum::http::Uri,
1941) -> AdminError {
1942 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
1943 AdminError::NotFound
1944}
1945
1946async fn not_found_plain() -> AdminError {
1948 AdminError::NotFound
1949}
1950
1951fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1953 let cause = err
1954 .downcast_ref::<&str>()
1955 .map(|s| s.to_string())
1956 .or_else(|| err.downcast_ref::<String>().cloned())
1957 .unwrap_or_else(|| "unknown panic".to_string());
1958 tracing::error!(panic = cause, "admin handler panicked");
1959 error::masked_500()
1960}
1961
1962pub fn normalize_path(path: &str) -> String {
1967 let trimmed = path.trim().trim_matches('/');
1968 if trimmed.is_empty() {
1969 "/admin".to_string()
1970 } else {
1971 format!("/{trimmed}")
1972 }
1973}
1974
1975#[allow(clippy::too_many_arguments)]
1979fn mount_public(
1980 regs: &[routes::PublicRouteReg],
1981 db: &Db,
1982 auth: &AuthService,
1983 field_types: &Arc<field::FieldRegistry>,
1984 overrides: &Arc<dyn field::OverrideResolver>,
1985 admin_path: &str,
1986 origin: &str,
1987 plugin_defined: &Arc<laterite_core::Registry>,
1988 events: &laterite_core::Events,
1989 forms: &form::Forms,
1990) -> Router<AdminState> {
1991 let mut router = Router::new();
1992 for reg in regs.iter().filter(|r| !r.is_site()) {
1994 let ctx = routes::RouteCtx::new(
1995 db.clone(),
1996 auth.clone(),
1997 field_types.clone(),
1998 overrides.clone(),
1999 ®.path,
2000 admin_path,
2001 origin,
2002 plugin_defined.clone(),
2003 events.clone(),
2004 forms.clone(),
2005 );
2006 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
2011 }
2012 router
2013}
2014
2015pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
2018 let old = resource.base_path.clone();
2019 resource.base_path = base.to_string();
2020 if let Some(edit_base) = &mut resource.list.edit_base {
2021 if edit_base == &old {
2022 *edit_base = base.to_string();
2023 }
2024 }
2025 if let Some(form) = &mut resource.form {
2026 if form.base_path == old {
2027 form.base_path = base.to_string();
2028 }
2029 }
2030}
2031
2032fn prefix_resource(admin_path: &str, resource: &mut Resource) {
2033 resource.base_path = format!("{admin_path}{}", resource.base_path);
2034 if let Some(edit_base) = &mut resource.list.edit_base {
2035 *edit_base = format!("{admin_path}{edit_base}");
2036 }
2037 if let Some(form) = &mut resource.form {
2038 form.base_path = format!("{admin_path}{}", form.base_path);
2039 }
2040}
2041
2042pub(crate) struct AdminAsset {
2044 pub mime: &'static str,
2045 pub bytes: std::borrow::Cow<'static, [u8]>,
2049}
2050
2051pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2054
2055pub(crate) type AssetUrls = HashMap<&'static str, String>;
2059
2060pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2062 registry
2063 .iter()
2064 .map(|(&key, asset)| {
2065 (
2066 key,
2067 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2068 )
2069 })
2070 .collect()
2071}
2072
2073pub(crate) fn page_assets(
2080 keys: &[&str],
2081 base: &str,
2082 registry: &AssetRegistry,
2083 urls: &AssetUrls,
2084) -> Vec<PageAsset> {
2085 let mut seen = std::collections::HashSet::new();
2086 let mut out = Vec::new();
2087 for &key in keys {
2088 if !seen.insert(key) {
2089 continue;
2090 }
2091 match (registry.get(key), urls.get(key)) {
2092 (Some(asset), Some(path)) => out.push(PageAsset {
2093 url: format!("{base}/assets/{path}"),
2094 css: asset.mime.contains("css"),
2095 }),
2096 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2097 }
2098 }
2099 out
2100}
2101
2102pub(crate) fn builtin_assets() -> AssetRegistry {
2111 let font = |bytes: &'static [u8]| AdminAsset {
2112 mime: "font/woff2",
2113 bytes: std::borrow::Cow::Borrowed(bytes),
2114 };
2115 HashMap::from([
2116 (
2117 "laterite.css",
2118 AdminAsset {
2119 mime: "text/css; charset=utf-8",
2120 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2121 },
2122 ),
2123 (
2124 "laterite.js",
2125 AdminAsset {
2126 mime: "text/javascript; charset=utf-8",
2127 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2128 },
2129 ),
2130 (
2131 "vendor/htmx.min.js",
2132 AdminAsset {
2133 mime: "text/javascript; charset=utf-8",
2134 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2135 },
2136 ),
2137 (
2138 "fields/ref-picker.js",
2139 AdminAsset {
2140 mime: "text/javascript; charset=utf-8",
2141 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
2142 },
2143 ),
2144 (
2145 "fields/ref-picker.css",
2146 AdminAsset {
2147 mime: "text/css; charset=utf-8",
2148 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2149 "../assets/fields/ref-picker.css"
2150 )),
2151 },
2152 ),
2153 (
2154 "mark.svg",
2155 AdminAsset {
2156 mime: "image/svg+xml",
2157 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
2158 },
2159 ),
2160 (
2161 "mark.png",
2162 AdminAsset {
2163 mime: "image/png",
2164 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
2165 },
2166 ),
2167 (
2168 "fonts/space-grotesk-500.woff2",
2169 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
2170 ),
2171 (
2172 "fonts/space-grotesk-600.woff2",
2173 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
2174 ),
2175 (
2176 "fonts/space-grotesk-700.woff2",
2177 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
2178 ),
2179 (
2180 "fonts/ibm-plex-sans-400.woff2",
2181 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
2182 ),
2183 (
2184 "fonts/ibm-plex-sans-600.woff2",
2185 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
2186 ),
2187 (
2188 "fonts/ibm-plex-mono-400.woff2",
2189 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
2190 ),
2191 (
2192 "fonts/ibm-plex-mono-600.woff2",
2193 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
2194 ),
2195 ])
2196}
2197
2198async fn serve_asset(
2200 State(state): State<AdminState>,
2201 headers: HeaderMap,
2202 Path(path): Path<String>,
2203) -> Response {
2204 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
2208 let Some(asset) = state.assets.get(key.as_str()) else {
2209 return not_found();
2210 };
2211 let policy = if fingerprinted {
2212 http_cache::IMMUTABLE
2213 } else {
2214 http_cache::REVALIDATE
2215 };
2216 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
2217}
2218
2219fn mount_resource(
2224 resource: &Resource,
2225 field_types: &field::FieldRegistry,
2226 column_types: &list::ColumnRegistry,
2227 permission_codes: &std::collections::HashSet<String>,
2228 persisters: &persist::PersisterRegistry,
2229 listeners: &[laterite_core::ModelListenerReg],
2230) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2231 list::check(&resource.list, column_types, permission_codes)
2235 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2236 if resource.form.is_some() && resource.permission.is_none() {
2241 panic!(
2242 "admin resource `{}` has a create/edit form but no permission; a write \
2243 resource must declare a permission",
2244 resource.base_path
2245 );
2246 }
2247 let base = resource.base_path.clone();
2248 let list_path = base.clone();
2249 let mut list_cfg = resource.list.clone();
2250 if resource.list.columns.iter().any(|c| c.editable) {
2253 let Some(form_cfg) = resource.form.clone() else {
2254 panic!(
2255 "admin resource `{}` has an editable column but no form to edit through",
2256 resource.base_path
2257 );
2258 };
2259 for column in resource.list.columns.iter().filter(|c| c.editable) {
2260 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2261 panic!(
2262 "admin resource `{}`: column `{}` is editable but the form has no such field",
2263 resource.base_path, column.field
2264 );
2265 }
2266 }
2267 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2268 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2269 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2270 }
2271 let cell_cfg = list_cfg.clone();
2272 let mut router = Router::new().route(
2273 &base,
2274 get(
2275 move |State(state): State<AdminState>,
2276 Extension(shell): Extension<Shell>,
2277 Query(params): Query<list::ListParams>,
2278 Query(raw): Query<std::collections::HashMap<String, String>>,
2279 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2280 Extension(user): Extension<AuthenticatedUser>,
2281 Extension(session): Extension<session::SessionHandle>,
2282 headers: axum::http::HeaderMap| {
2283 let cfg = list_cfg.clone();
2284 let path = list_path.clone();
2285 async move {
2286 list::handle_remembering(
2287 &state,
2288 &cfg,
2289 &path,
2290 params,
2291 &raw,
2292 &user,
2293 shell,
2294 &headers,
2295 query_string.as_deref(),
2296 Some(&session),
2297 )
2298 .await
2299 }
2300 },
2301 ),
2302 );
2303
2304 if cell_cfg.editor.is_some() {
2305 let (cfg, list_path) = (cell_cfg, base.clone());
2306 router = router.route(
2307 &format!("{base}/{{id}}/cell"),
2308 post(
2309 move |State(state): State<AdminState>,
2310 Extension(shell): Extension<Shell>,
2311 Extension(user): Extension<AuthenticatedUser>,
2312 Extension(session): Extension<session::SessionHandle>,
2313 Path(id): Path<String>,
2314 headers: axum::http::HeaderMap,
2315 Form(data): Form<HashMap<String, String>>| {
2316 let (cfg, path) = (cfg.clone(), list_path.clone());
2317 async move {
2318 list::cell_handler(
2319 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2320 )
2321 .await
2322 }
2323 },
2324 ),
2325 );
2326 }
2327
2328 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2331 if resource.list.exportable {
2332 router = router.route(
2333 &format!("{base}/export"),
2334 get(
2335 move |state: State<AdminState>,
2336 Query(raw): Query<std::collections::HashMap<String, String>>,
2337 Extension(user): Extension<AuthenticatedUser>,
2338 Extension(shell): Extension<Shell>,
2339 Extension(session): Extension<session::SessionHandle>| {
2340 let cfg = export_cfg.clone();
2341 let path = export_path.clone();
2342 async move {
2343 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2344 }
2345 },
2346 ),
2347 );
2348 }
2349
2350 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2351 router = router.route(
2352 &format!("{base}/columns"),
2353 post(
2354 move |State(state): State<AdminState>,
2355 Extension(user): Extension<AuthenticatedUser>,
2356 Extension(session): Extension<session::SessionHandle>,
2357 headers: axum::http::HeaderMap,
2358 Form(pairs): Form<Vec<(String, String)>>| {
2359 let cfg = columns_cfg.clone();
2360 let path = columns_path.clone();
2361 async move {
2362 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2363 }
2364 },
2365 ),
2366 );
2367
2368 if resource.list.deletable {
2369 let persister: Arc<dyn persist::Persister> =
2373 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
2374 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
2375 panic!(
2376 "admin resource `{}`: unregistered persister `{key}`",
2377 resource.base_path
2378 )
2379 }),
2380 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
2381 };
2382 let ctx = Arc::new(bulk::BulkContext {
2383 base_path: resource.base_path.clone(),
2384 entity: resource.list.entity.clone(),
2385 persister,
2386 listeners: listeners
2387 .iter()
2388 .filter(|reg| reg.matches(&resource.list.entity))
2389 .map(|reg| reg.listener.clone())
2390 .collect(),
2391 });
2392 router =
2393 router.route(
2394 &format!("{base}/delete"),
2395 post(
2396 move |state: State<AdminState>,
2397 user: Extension<AuthenticatedUser>,
2398 session: Extension<session::SessionHandle>,
2399 headers: axum::http::HeaderMap,
2400 form: Form<Vec<(String, String)>>| {
2401 let ctx = ctx.clone();
2402 async move {
2403 bulk::delete_handler(state, user, session, ctx, headers, form).await
2404 }
2405 },
2406 ),
2407 );
2408 }
2409
2410 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
2413 let tree = resource.list.tree.clone().unwrap_or_default();
2414 let mut move_cfg = form::FormConfig::new(
2415 resource.list.entity.clone(),
2416 laterite_core::t!("Move to"),
2417 base.clone(),
2418 resource.list.id_field.clone(),
2419 vec![form::FormField::reference(
2420 &tree.parent_key,
2421 laterite_core::t!("New parent"),
2422 &source,
2423 )
2424 .help(laterite_core::t!("Leave empty for the top level."))],
2425 );
2426 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
2427 move_cfg = move_cfg.persist(persist);
2428 }
2429 move_cfg.done_path = Some(base.clone());
2430 let move_form = Arc::new(
2431 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
2432 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
2433 );
2434 let (cfg, list_path) = (resource.list.clone(), base.clone());
2435 let (form_get, form_post) = (move_form.clone(), move_form);
2436 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
2437 router = router.route(
2438 &format!("{base}/{{id}}/move"),
2439 get(
2440 move |State(state): State<AdminState>,
2441 Extension(shell): Extension<Shell>,
2442 Path(id): Path<String>| {
2443 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
2444 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
2445 },
2446 )
2447 .post(
2448 move |State(state): State<AdminState>,
2449 Extension(shell): Extension<Shell>,
2450 Extension(user): Extension<AuthenticatedUser>,
2451 Extension(session): Extension<session::SessionHandle>,
2452 Path(id): Path<String>,
2453 headers: axum::http::HeaderMap,
2454 Form(data): Form<HashMap<String, String>>| {
2455 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
2456 async move {
2457 list::move_record(
2458 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
2459 )
2460 .await
2461 }
2462 },
2463 ),
2464 );
2465 }
2466
2467 if resource.list.reorder.is_some() {
2468 let (cfg, list_path) = (resource.list.clone(), base.clone());
2469 router = router.route(
2470 &format!("{base}/reorder"),
2471 post(
2472 move |State(state): State<AdminState>,
2473 Extension(shell): Extension<Shell>,
2474 Extension(user): Extension<AuthenticatedUser>,
2475 Extension(session): Extension<session::SessionHandle>,
2476 headers: axum::http::HeaderMap,
2477 Form(pairs): Form<Vec<(String, String)>>| {
2478 let cfg = cfg.clone();
2479 let path = list_path.clone();
2480 async move {
2481 list::reorder_handler(
2482 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
2483 &headers, &pairs,
2484 )
2485 .await
2486 }
2487 },
2488 ),
2489 );
2490 }
2491
2492 if !resource.relations.is_empty() && resource.form.is_none() {
2495 panic!(
2496 "admin resource `{}` declares relations but has no form to show them on",
2497 resource.base_path
2498 );
2499 }
2500 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
2501 for rel in &resource.relations {
2502 let prepared = relation::PreparedRelation::prepare(
2503 resource,
2504 rel.clone(),
2505 field_types,
2506 column_types,
2507 permission_codes,
2508 persisters,
2509 listeners,
2510 )
2511 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2512 relations.push(Arc::new(prepared));
2513 }
2514 let relations = Arc::new(relations);
2515
2516 let mut prepared_form = None;
2517 if let Some(form_cfg) = resource.form.clone() {
2518 let mut prepared =
2521 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2522 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2523 prepared.stay = !relations.is_empty();
2525 let prepared = Arc::new(prepared);
2526 prepared_form = Some(prepared.clone());
2527 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
2528 let relations_for_new = relations.clone();
2529 router = router.route(
2530 &format!("{base}/new"),
2531 get(
2532 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
2533 let pf = new_pf.clone();
2534 let relations = relations_for_new.clone();
2535 async move {
2536 let pending = relations
2537 .iter()
2538 .map(|r| shell.tt(&r.config.title))
2539 .collect();
2540 form::new_form(&state, &pf, shell, pending)
2541 }
2542 },
2543 )
2544 .post(
2545 move |State(state): State<AdminState>,
2546 Extension(shell): Extension<Shell>,
2547 Extension(user): Extension<AuthenticatedUser>,
2548 Extension(session): Extension<session::SessionHandle>,
2549 headers: axum::http::HeaderMap,
2550 Form(data): Form<HashMap<String, String>>| {
2551 let pf = create_pf.clone();
2552 async move {
2553 form::create(&state, &pf, data, shell, &user, &session, &headers).await
2554 }
2555 },
2556 ),
2557 );
2558
2559 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
2560 let relations_for_edit = relations.clone();
2561 router = router.route(
2562 &format!("{base}/{{id}}/edit"),
2563 get(
2564 move |State(state): State<AdminState>,
2565 Extension(shell): Extension<Shell>,
2566 Extension(user): Extension<AuthenticatedUser>,
2567 Path(id): Path<String>| {
2568 let pf = edit_pf.clone();
2569 let relations = relations_for_edit.clone();
2570 async move {
2571 let mut sections = Vec::new();
2572 for rel in relations.iter() {
2573 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
2574 sections.push(section);
2575 }
2576 }
2577 form::edit_form(&state, &pf, id, shell, sections).await
2578 }
2579 },
2580 )
2581 .post(
2582 move |State(state): State<AdminState>,
2583 Extension(shell): Extension<Shell>,
2584 Extension(user): Extension<AuthenticatedUser>,
2585 Extension(session): Extension<session::SessionHandle>,
2586 Path(id): Path<String>,
2587 headers: axum::http::HeaderMap,
2588 Form(data): Form<HashMap<String, String>>| {
2589 let pf = update_pf.clone();
2590 async move {
2591 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
2592 }
2593 },
2594 ),
2595 );
2596 }
2597
2598 for rel in relations.iter() {
2599 let name = rel.config.name.clone();
2600 let mut routes: Router<AdminState> = Router::new();
2601 let r = rel.clone();
2602 routes =
2603 routes.route(
2604 &format!("{base}/{{id}}/{name}"),
2605 get(
2606 move |state: State<AdminState>,
2607 shell: Extension<Shell>,
2608 user: Extension<AuthenticatedUser>,
2609 id: Path<String>,
2610 params: Query<list::ListParams>,
2611 raw: Query<HashMap<String, String>>,
2612 headers: axum::http::HeaderMap| {
2613 let r = r.clone();
2614 async move {
2615 relation::index(state, shell, user, id, params, raw, headers, r).await
2616 }
2617 },
2618 ),
2619 );
2620 let (r_new, r_create) = (rel.clone(), rel.clone());
2621 routes = routes.route(
2622 &format!("{base}/{{id}}/{name}/new"),
2623 get(
2624 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
2625 let r = r_new.clone();
2626 async move { relation::new_form(state, shell, id, r).await }
2627 },
2628 )
2629 .post(
2630 move |state: State<AdminState>,
2631 shell: Extension<Shell>,
2632 user: Extension<AuthenticatedUser>,
2633 session: Extension<session::SessionHandle>,
2634 id: Path<String>,
2635 headers: axum::http::HeaderMap,
2636 data: Form<HashMap<String, String>>| {
2637 let r = r_create.clone();
2638 async move {
2639 relation::create(state, shell, user, session, id, headers, data, r).await
2640 }
2641 },
2642 ),
2643 );
2644 let (r_edit, r_update) = (rel.clone(), rel.clone());
2645 routes = routes.route(
2646 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
2647 get(
2648 move |state: State<AdminState>,
2649 shell: Extension<Shell>,
2650 ids: Path<(String, String)>| {
2651 let r = r_edit.clone();
2652 async move { relation::edit_form(state, shell, ids, r).await }
2653 },
2654 )
2655 .post(
2656 move |state: State<AdminState>,
2657 shell: Extension<Shell>,
2658 user: Extension<AuthenticatedUser>,
2659 session: Extension<session::SessionHandle>,
2660 ids: Path<(String, String)>,
2661 headers: axum::http::HeaderMap,
2662 data: Form<HashMap<String, String>>| {
2663 let r = r_update.clone();
2664 async move {
2665 relation::update(state, shell, user, session, ids, headers, data, r).await
2666 }
2667 },
2668 ),
2669 );
2670 let r_delete = rel.clone();
2671 routes = routes.route(
2672 &format!("{base}/{{id}}/{name}/delete"),
2673 post(
2674 move |state: State<AdminState>,
2675 shell: Extension<Shell>,
2676 user: Extension<AuthenticatedUser>,
2677 session: Extension<session::SessionHandle>,
2678 id: Path<String>,
2679 headers: axum::http::HeaderMap,
2680 pairs: Form<Vec<(String, String)>>| {
2681 let r = r_delete.clone();
2682 async move {
2683 relation::delete(state, shell, user, session, id, headers, pairs, r).await
2684 }
2685 },
2686 ),
2687 );
2688 if rel.reorderable() {
2689 let r_reorder = rel.clone();
2690 routes = routes.route(
2691 &format!("{base}/{{id}}/{name}/reorder"),
2692 post(
2693 move |state: State<AdminState>,
2694 shell: Extension<Shell>,
2695 user: Extension<AuthenticatedUser>,
2696 session: Extension<session::SessionHandle>,
2697 id: Path<String>,
2698 headers: axum::http::HeaderMap,
2699 pairs: Form<Vec<(String, String)>>| {
2700 let r = r_reorder.clone();
2701 async move {
2702 relation::reorder(state, shell, user, session, id, headers, pairs, r)
2703 .await
2704 }
2705 },
2706 ),
2707 );
2708 }
2709 if let Some(permission) = &rel.config.permission {
2710 routes = guard_with_permission(routes, permission);
2711 }
2712 router = router.merge(routes);
2713 }
2714
2715 if let Some(permission) = &resource.permission {
2717 router = guard_with_permission(router, permission);
2718 }
2719 (router, prepared_form)
2720}
2721
2722fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
2727 let needed: Arc<str> = Arc::from(permission);
2728 router.route_layer(middleware::from_fn(
2729 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
2730 let needed = needed.clone();
2731 async move {
2732 if user.allows(&needed) {
2733 next.run(req).await
2734 } else {
2735 forbidden()
2736 }
2737 }
2738 },
2739 ))
2740}
2741
2742async fn require_auth(
2748 State(state): State<AdminState>,
2749 jar: CookieJar,
2750 request: Request,
2751 next: Next,
2752) -> Response {
2753 let login = format!("{}/login", state.admin_path);
2754 let client_ctx = request
2755 .extensions()
2756 .get::<RequestContext>()
2757 .cloned()
2758 .unwrap_or_default();
2759 let mut ended: Option<laterite_auth::SessionEnd> = None;
2764 let live = match jar.get(SESSION_COOKIE) {
2765 Some(cookie) => {
2766 let token = cookie.value().to_string();
2767 match state.auth.resolve_session(&token).await {
2768 Ok(resolved) => Some((token, resolved)),
2769 Err(_) => {
2770 ended = state.auth.session_end(&token).await.ok().flatten();
2771 None
2772 }
2773 }
2774 }
2775 None => None,
2776 };
2777 let htmx = request.headers().contains_key("hx-request");
2778 let (token, resolved, recalled) = match live {
2779 Some((token, resolved)) => (token, resolved, None),
2780 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2783 return signed_out(&state, jar, &login, ended, htmx);
2784 }
2785 None => match recall(&state, &jar, &client_ctx).await {
2786 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2787 None => {
2788 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2792 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2793 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2794 }
2795 }
2796 return signed_out(&state, jar, &login, ended, htmx);
2797 }
2798 },
2799 };
2800 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2801
2802 let mut request = request;
2807 let safe = session::is_safe_method(request.method());
2808 let mut deferred: Option<session::CsrfPending> = None;
2810 if !safe {
2811 let (parts, body) = request.into_parts();
2812 let (body, submitted) = if session::is_multipart(&parts.headers) {
2819 let token = session::header_token(&parts.headers)
2820 .or_else(|| session::query_token(parts.uri.query()));
2821 if token.is_none() {
2826 deferred = Some(session::CsrfPending::default());
2827 }
2828 (body, token)
2829 } else {
2830 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2831 .await
2832 .unwrap_or_default();
2833 let token = session::submitted_token(&parts.headers, &bytes);
2834 (Body::from(bytes), token)
2835 };
2836 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2837 {
2838 tracing::warn!(
2839 user_id = resolved.identity.user.id,
2840 "admin CSRF check failed"
2841 );
2842 return error::csrf_rejected();
2843 }
2844 request = Request::from_parts(parts, body);
2845 }
2846
2847 let user = resolved.identity;
2848 let acting_user_id = user.user.id;
2850 let path = request.uri().path().to_string();
2851 let preferences = format!("{}/preferences", state.admin_path);
2854 if !path.starts_with(&preferences)
2855 && path != format!("{}/logout", state.admin_path)
2856 && state
2857 .auth
2858 .must_change_password(user.user.id)
2859 .await
2860 .unwrap_or(false)
2861 {
2862 let to = format!("{preferences}#password");
2863 let response = if htmx {
2864 (
2865 [("HX-Redirect", to.as_str())],
2866 axum::http::StatusCode::NO_CONTENT,
2867 )
2868 .into_response()
2869 } else {
2870 Redirect::to(&to).into_response()
2871 };
2872 return with_recall_cookies(response, recalled, jar, token, &state);
2873 }
2874 let flash = if safe {
2877 handle.take_flash()
2878 } else {
2879 Vec::new()
2880 };
2881 let accept_language = request
2885 .headers()
2886 .get("accept-language")
2887 .and_then(|v| v.to_str().ok());
2888 let serveable = state.catalogs.locales();
2889 let chain = resolve_locale_chain(
2890 user.user.locale.as_deref(),
2891 accept_language,
2892 &state.default_locale,
2893 &serveable,
2894 );
2895 let i18n = Translator::with_chain(chain, state.catalogs.clone());
2896 let translator = i18n.clone();
2899 let own_order =
2901 laterite_auth::store::user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
2902 .await
2903 .unwrap_or_default();
2904 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
2905 &state.nav,
2906 &state.settings,
2907 &state.admin_path,
2908 &user.permissions,
2909 own_order.as_deref(),
2910 &path,
2911 &i18n,
2912 icons::Icons::new(&state.icons, &state.sprite_url),
2913 );
2914 let brand = state.brand().await;
2915 let shell = Shell::new(
2916 &state.admin_path,
2917 brand,
2918 &visible_nav,
2919 &user,
2920 state.timezone,
2921 sidebar,
2922 active_nav.as_deref(),
2923 handle.csrf_token(),
2924 flash,
2925 i18n,
2926 state.asset_urls.clone(),
2927 state.origin.to_string(),
2928 state.icons.clone(),
2929 state.sprite_url.clone(),
2930 );
2931 request.extensions_mut().insert(user);
2932 request.extensions_mut().insert(shell);
2933 request.extensions_mut().insert(handle.clone());
2934 if let Some(pending) = &deferred {
2936 request.extensions_mut().insert(pending.clone());
2937 }
2938 let mut response = next.run(request).await;
2939
2940 if let Some(pending) = &deferred {
2945 if !pending.was_satisfied() {
2946 tracing::error!(
2947 user_id = acting_user_id,
2948 "a multipart route returned without verifying its request token"
2949 );
2950 return error::csrf_rejected();
2951 }
2952 }
2953
2954 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2958 response = error::localized_error(meta.kind, &translator);
2959 }
2960
2961 if let Some(blob) = handle.dirty_blob() {
2964 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2965 tracing::error!(error = %e, "persisting admin session failed");
2966 }
2967 }
2968 with_recall_cookies(response, recalled, jar, token, &state)
2969}
2970
2971fn with_recall_cookies(
2975 response: Response,
2976 recalled: Option<laterite_auth::RememberCredential>,
2977 jar: CookieJar,
2978 token: String,
2979 state: &AdminState,
2980) -> Response {
2981 match recalled {
2982 Some(credential) => {
2983 let jar = jar
2984 .add(session_cookie(
2985 token,
2986 &state.admin_path,
2987 state.secure_cookie,
2988 ))
2989 .add(remember_cookie(
2990 credential,
2991 &state.admin_path,
2992 state.secure_cookie,
2993 ));
2994 (jar, response).into_response()
2995 }
2996 None => response,
2997 }
2998}
2999
3000async fn recall(
3004 state: &AdminState,
3005 jar: &CookieJar,
3006 ctx: &RequestContext,
3007) -> Option<(
3008 String,
3009 laterite_auth::ResolvedSession,
3010 laterite_auth::RememberCredential,
3011)> {
3012 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
3013 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
3014 let token = recalled.session.token.clone();
3015 let resolved = state.auth.resolve_session(&token).await.ok()?;
3016 Some((token, resolved, recalled.remember))
3017}
3018
3019const MAX_FORM_BYTES: usize = 1024 * 1024;
3022
3023async fn capture_client(
3031 State(state): State<AdminState>,
3032 mut request: Request,
3033 next: Next,
3034) -> Response {
3035 let peer = request
3036 .extensions()
3037 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3038 .map(|info| info.0);
3039 let headers = request.headers();
3040 let ctx = RequestContext {
3041 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3042 user_agent: clientip::user_agent(headers),
3043 };
3044 request.extensions_mut().insert(ctx);
3045 next.run(request).await
3046}
3047
3048async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3049 if !session::is_safe_method(request.method())
3050 && !session::origin_ok(request.headers(), &state.origin)
3051 {
3052 tracing::warn!(
3053 path = request.uri().path(),
3054 "admin CSRF origin check failed"
3055 );
3056 return error::csrf_rejected();
3057 }
3058 next.run(request).await
3059}
3060
3061#[derive(Deserialize)]
3062struct LoginQuery {
3063 ended: Option<String>,
3064}
3065
3066async fn login_form(
3067 State(state): State<AdminState>,
3068 headers: HeaderMap,
3069 Query(query): Query<LoginQuery>,
3070) -> Response {
3071 match state.auth.has_any_operator().await {
3073 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
3074 Ok(true) => {
3075 let i18n = pre_auth_translator(&state, &headers);
3076 let notice = query
3077 .ended
3078 .as_deref()
3079 .and_then(end_notice)
3080 .map(|text| i18n.t(&text));
3081 render(LoginTemplate {
3082 base: state.admin_path.to_string(),
3083 asset_urls: state.asset_urls.clone(),
3084 brand: state.brand().await,
3085 error: None,
3086 notice,
3087 i18n,
3088 })
3089 }
3090 Err(_) => render_error(),
3091 }
3092}
3093
3094#[derive(Deserialize)]
3095struct LoginForm {
3096 username: String,
3097 password: String,
3098 #[serde(default)]
3100 remember: Option<String>,
3101}
3102
3103async fn login_submit(
3104 State(state): State<AdminState>,
3105 jar: CookieJar,
3106 headers: HeaderMap,
3107 Extension(ctx): Extension<RequestContext>,
3108 Form(form): Form<LoginForm>,
3109) -> Response {
3110 match state
3111 .auth
3112 .authenticate(&form.username, &form.password, &ctx)
3113 .await
3114 {
3115 Ok(session) => {
3116 let wants_remember = form.remember.is_some();
3117 let user_id = session.user_id;
3118 let mut jar = jar.add(session_cookie(
3119 session.token,
3120 &state.admin_path,
3121 state.secure_cookie,
3122 ));
3123 if wants_remember {
3124 match state.auth.issue_remember(user_id).await {
3125 Ok(credential) => {
3126 jar = jar.add(remember_cookie(
3127 credential,
3128 &state.admin_path,
3129 state.secure_cookie,
3130 ));
3131 }
3132 Err(e) => {
3134 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
3135 }
3136 }
3137 }
3138 (jar, Redirect::to(&state.admin_path)).into_response()
3139 }
3140 Err(_) => {
3141 let i18n = pre_auth_translator(&state, &headers);
3142 render(LoginTemplate {
3143 base: state.admin_path.to_string(),
3144 asset_urls: state.asset_urls.clone(),
3145 brand: state.brand().await,
3146 error: Some(i18n.t(&t!("Invalid username or password."))),
3147 notice: None,
3148 i18n,
3149 })
3150 }
3151 }
3152}
3153
3154fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
3163 Cookie::build((SESSION_COOKIE, token))
3164 .path(admin_path.to_string())
3165 .http_only(true)
3166 .secure(secure)
3167 .same_site(SameSite::Lax)
3168 .build()
3169}
3170
3171fn remember_cookie(
3174 credential: laterite_auth::RememberCredential,
3175 admin_path: &str,
3176 secure: bool,
3177) -> Cookie<'static> {
3178 let seconds = (credential.expires_at - chrono::Utc::now())
3179 .num_seconds()
3180 .max(0);
3181 Cookie::build((REMEMBER_COOKIE, credential.cookie))
3182 .path(admin_path.to_string())
3183 .http_only(true)
3184 .secure(secure)
3185 .same_site(SameSite::Lax)
3186 .max_age(cookie::time::Duration::seconds(seconds))
3187 .build()
3188}
3189
3190fn signed_out(
3197 state: &AdminState,
3198 jar: CookieJar,
3199 login: &str,
3200 ended: Option<laterite_auth::SessionEnd>,
3201 htmx: bool,
3202) -> Response {
3203 let to = match ended.map(laterite_auth::SessionEnd::code) {
3204 Some(code) => format!("{login}?ended={code}"),
3205 None => login.to_string(),
3206 };
3207 let jar = jar
3208 .remove(
3209 Cookie::build((SESSION_COOKIE, ""))
3210 .path(state.admin_path.to_string())
3211 .build(),
3212 )
3213 .remove(remember_removal(&state.admin_path));
3214 if htmx {
3215 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
3216 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
3217 response.headers_mut().insert("hx-redirect", value);
3218 }
3219 return response;
3220 }
3221 (jar, Redirect::to(&to)).into_response()
3222}
3223
3224fn end_notice(code: &str) -> Option<Text> {
3227 Some(match code {
3228 "password_changed" => {
3229 t!("Your password was changed, so you were signed out. Sign in with the new one.")
3230 }
3231 "deactivated" => t!("This account was deactivated."),
3232 "signed_out_elsewhere" => t!("You were signed out from another device."),
3233 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3234 _ => return None,
3235 })
3236}
3237
3238fn remember_removal(admin_path: &str) -> Cookie<'static> {
3240 Cookie::build((REMEMBER_COOKIE, ""))
3241 .path(admin_path.to_string())
3242 .build()
3243}
3244
3245#[derive(Deserialize)]
3246struct SetupForm {
3247 username: String,
3248 first_name: String,
3249 last_name: String,
3250 email: String,
3251 password: String,
3252 timezone: String,
3253}
3254
3255async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3258 match state.auth.has_any_operator().await {
3259 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3260 Ok(false) => render(setup_view(
3261 &state.admin_path,
3262 state.brand().await,
3263 state.timezone,
3264 None,
3265 pre_auth_translator(&state, &headers),
3266 state.asset_urls.clone(),
3267 state.auth.password_policy().min_length,
3268 )),
3269 Err(_) => render_error(),
3270 }
3271}
3272
3273async fn setup_submit(
3274 State(state): State<AdminState>,
3275 jar: CookieJar,
3276 headers: HeaderMap,
3277 client: Option<Extension<RequestContext>>,
3278 Form(form): Form<SetupForm>,
3279) -> Response {
3280 match state.auth.has_any_operator().await {
3282 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3283 Ok(false) => {}
3284 Err(_) => return render_error(),
3285 }
3286
3287 let username = form.username.trim();
3288 let email = form.email.trim();
3289 let first_name = form.first_name.trim();
3290 let last_name = form.last_name.trim();
3291 let tz = form.timezone.trim();
3292 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3293 {
3294 return render(setup_view(
3295 &state.admin_path,
3296 state.brand().await,
3297 state.timezone,
3298 Some(t!(
3299 "Username, first name, email, and password are all required."
3300 )),
3301 pre_auth_translator(&state, &headers),
3302 state.asset_urls.clone(),
3303 state.auth.password_policy().min_length,
3304 ));
3305 }
3306 if tz.parse::<Tz>().is_err() {
3308 return render(setup_view(
3309 &state.admin_path,
3310 state.brand().await,
3311 state.timezone,
3312 Some(t!("That is not a recognised timezone.")),
3313 pre_auth_translator(&state, &headers),
3314 state.asset_urls.clone(),
3315 state.auth.password_policy().min_length,
3316 ));
3317 }
3318
3319 let min_length = state.auth.password_policy().min_length;
3320 if form.password.chars().count() < min_length {
3321 return render(setup_view(
3322 &state.admin_path,
3323 state.brand().await,
3324 state.timezone,
3325 Some(t!(
3326 "A password needs at least {n} characters.",
3327 n = min_length as i64
3328 )),
3329 pre_auth_translator(&state, &headers),
3330 state.asset_urls.clone(),
3331 state.auth.password_policy().min_length,
3332 ));
3333 }
3334 let new = NewOperator {
3335 username,
3336 email,
3337 first_name,
3338 last_name: (!last_name.is_empty()).then_some(last_name),
3339 password: &form.password,
3340 timezone: Some(tz),
3341 };
3342 let id = match state.auth.create_superuser(new).await {
3343 Ok(id) => id,
3344 Err(_) => {
3345 return render(setup_view(
3346 &state.admin_path,
3347 state.brand().await,
3348 state.timezone,
3349 Some(t!(
3350 "Could not create the account. The username or email may already be taken."
3351 )),
3352 pre_auth_translator(&state, &headers),
3353 state.asset_urls.clone(),
3354 state.auth.password_policy().min_length,
3355 ));
3356 }
3357 };
3358 let actor = laterite_core::Actor::system("first-run setup");
3361 let target = id.to_string();
3362 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
3363 "backend_user",
3364 &target,
3365 Some(username),
3366 );
3367 if let Err(e) = state.auth.record_audit(entry).await {
3368 tracing::error!(error = %e, "failed to write audit log entry");
3369 }
3370
3371 match state
3373 .auth
3374 .authenticate(
3375 username,
3376 &form.password,
3377 &client.map(|c| c.0).unwrap_or_default(),
3378 )
3379 .await
3380 {
3381 Ok(session) => {
3382 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
3383 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
3384 }
3385 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3386 }
3387}
3388
3389fn setup_view(
3392 admin_path: &str,
3393 brand: String,
3394 default_tz: Tz,
3395 error: Option<Text>,
3396 i18n: Translator,
3397 asset_urls: Arc<AssetUrls>,
3398 min_password: usize,
3399) -> SetupTemplate {
3400 let default_name = default_tz.name();
3401 let zones = TZ_VARIANTS
3402 .iter()
3403 .filter(|tz| zone_offered(tz.name()))
3404 .map(|tz| TzOption {
3405 name: tz.name().to_string(),
3406 selected: tz.name() == default_name,
3407 })
3408 .collect();
3409 SetupTemplate {
3410 base: admin_path.to_string(),
3411 asset_urls,
3412 brand,
3413 zones,
3414 error: error.map(|e| i18n.t(&e)),
3415 min_password: min_password as i64,
3416 i18n,
3417 }
3418}
3419
3420async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
3421 if let Some(cookie) = jar.get(SESSION_COOKIE) {
3422 let _ = state.auth.logout(cookie.value()).await;
3423 }
3424 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3427 let _ = state.auth.revoke_remember(cookie.value()).await;
3428 }
3429 let removal = Cookie::build((SESSION_COOKIE, ""))
3430 .path(state.admin_path.to_string())
3431 .build();
3432 (
3433 jar.remove(removal)
3434 .remove(remember_removal(&state.admin_path)),
3435 Redirect::to(&format!("{}/login", state.admin_path)),
3436 )
3437 .into_response()
3438}
3439
3440async fn dashboard(
3441 Extension(shell): Extension<Shell>,
3442 Extension(user): Extension<AuthenticatedUser>,
3443) -> Response {
3444 render(DashboardTemplate {
3445 username: user.user.username,
3446 shell,
3447 })
3448}
3449
3450fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
3453 match &item.permission {
3454 None => true,
3455 Some(p) => perms.allows(p),
3456 }
3457}
3458
3459fn visible_settings(
3463 items: &[settings::SettingsItem],
3464 perms: &PermissionSet,
3465) -> Vec<settings::SettingsItem> {
3466 items
3467 .iter()
3468 .filter(|item| operator_can_see(item, perms))
3469 .cloned()
3470 .collect()
3471}
3472
3473async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
3474 settings::index(shell)
3475}
3476
3477async fn settings_edit(
3478 state: AdminState,
3479 shell: Shell,
3480 user: AuthenticatedUser,
3481 code: String,
3482) -> Response {
3483 let items = visible_settings(&state.settings, &user.permissions);
3486 match items
3487 .iter()
3488 .find(|item| item.code == code && item.link.is_none())
3489 {
3490 Some(item) => settings::edit_form(&state, item, shell, &user).await,
3491 None => not_found(),
3492 }
3493}
3494
3495async fn settings_update(
3496 state: AdminState,
3497 shell: Shell,
3498 user: AuthenticatedUser,
3499 session: session::SessionHandle,
3500 code: String,
3501 data: HashMap<String, String>,
3502) -> Response {
3503 let items = visible_settings(&state.settings, &user.permissions);
3504 match items
3505 .iter()
3506 .find(|item| item.code == code && item.link.is_none())
3507 {
3508 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
3509 None => not_found(),
3510 }
3511}
3512
3513async fn preferences_form(
3515 State(state): State<AdminState>,
3516 Extension(shell): Extension<Shell>,
3517 Extension(user): Extension<AuthenticatedUser>,
3518 jar: CookieJar,
3519) -> Response {
3520 let account = account_security(&state, &user, &shell, &jar).await;
3521 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
3522 .into_iter()
3523 .filter(|i| i.per_operator)
3524 .map(|i| PreferenceLink {
3525 label: shell.tt(&i.label),
3526 description: shell.tt(&i.description),
3527 href: i.path(&state.admin_path),
3528 })
3529 .collect();
3530 render(preferences_view(
3531 &shell,
3532 &user,
3533 state.timezone,
3534 &state.default_locale,
3535 &offered_locales(&state.catalogs),
3536 account,
3537 state.auth.password_policy().min_length,
3538 items,
3539 ))
3540}
3541
3542struct PreferenceLink {
3544 label: String,
3545 description: String,
3546 href: String,
3547}
3548
3549#[derive(Deserialize)]
3550struct RevokeSessionForm {
3551 id: String,
3552}
3553
3554async fn session_revoke(
3556 State(state): State<AdminState>,
3557 Extension(user): Extension<AuthenticatedUser>,
3558 Extension(session): Extension<session::SessionHandle>,
3559 Form(form): Form<RevokeSessionForm>,
3560) -> Response {
3561 match state.auth.revoke_session(user.user.id, &form.id).await {
3562 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
3563 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
3564 Err(e) => {
3565 tracing::error!(error = %e, "revoking a session failed");
3566 session.push_flash(
3567 FlashLevel::Error,
3568 t!("That session could not be signed out."),
3569 );
3570 }
3571 }
3572 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3573}
3574
3575#[derive(Deserialize)]
3577struct MenuOrderForm {
3578 #[serde(default)]
3579 order: String,
3580}
3581
3582async fn menu_order_update(
3587 State(state): State<AdminState>,
3588 Extension(user): Extension<AuthenticatedUser>,
3589 Extension(session): Extension<session::SessionHandle>,
3590 headers: axum::http::HeaderMap,
3591 Form(form): Form<MenuOrderForm>,
3592) -> Response {
3593 let order: Vec<&str> = form
3594 .order
3595 .split(',')
3596 .map(str::trim)
3597 .filter(|k| !k.is_empty() && k.len() <= 200)
3598 .take(100)
3599 .collect();
3600 let result = if order.is_empty() {
3601 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
3602 .await
3603 } else {
3604 laterite_auth::store::set_user_preference(
3605 &state.db,
3606 user.user.id,
3607 nav::ORDER_PREFERENCE,
3608 &order.join(","),
3609 )
3610 .await
3611 };
3612 if let Err(e) = result {
3613 tracing::error!(error = %e, "storing the menu order failed");
3614 return render_error();
3615 }
3616 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
3617 return axum::http::StatusCode::NO_CONTENT.into_response();
3618 }
3619 session.push_flash(
3620 FlashLevel::Success,
3621 if order.is_empty() {
3622 t!("The menu is back in its default order.")
3623 } else {
3624 t!("Saved.")
3625 },
3626 );
3627 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3628}
3629
3630async fn session_revoke_others(
3631 State(state): State<AdminState>,
3632 Extension(user): Extension<AuthenticatedUser>,
3633 Extension(session): Extension<session::SessionHandle>,
3634 jar: CookieJar,
3635) -> Response {
3636 let token = jar
3637 .get(SESSION_COOKIE)
3638 .map(|c| c.value().to_string())
3639 .unwrap_or_default();
3640 match state.auth.sign_out_everywhere(user.user.id, &token).await {
3641 Ok(_) => session.push_flash(
3642 FlashLevel::Success,
3643 t!("Every other session was signed out."),
3644 ),
3645 Err(e) => {
3646 tracing::error!(error = %e, "signing out other sessions failed");
3647 session.push_flash(
3648 FlashLevel::Error,
3649 t!("Those sessions could not be signed out."),
3650 );
3651 }
3652 }
3653 (
3656 jar.remove(remember_removal(&state.admin_path)),
3657 Redirect::to(&format!("{}/preferences", state.admin_path)),
3658 )
3659 .into_response()
3660}
3661
3662#[derive(Deserialize)]
3663struct PasswordForm {
3664 current_password: String,
3665 new_password: String,
3666 confirm_password: String,
3667}
3668
3669async fn password_update(
3674 State(state): State<AdminState>,
3675 Extension(user): Extension<AuthenticatedUser>,
3676 Extension(session): Extension<session::SessionHandle>,
3677 Extension(ctx): Extension<RequestContext>,
3678 jar: CookieJar,
3679 Form(form): Form<PasswordForm>,
3680) -> Response {
3681 let back = format!("{}/preferences#password", state.admin_path);
3682 let refuse = |message: Text| {
3683 session.push_flash(FlashLevel::Error, message);
3684 Redirect::to(&back).into_response()
3685 };
3686 if form.new_password != form.confirm_password {
3687 return refuse(t!("The new passwords do not match."));
3688 }
3689 let min_length = state.auth.password_policy().min_length;
3690 if form.new_password.chars().count() < min_length {
3691 return refuse(t!(
3692 "A password needs at least {n} characters.",
3693 n = min_length as i64
3694 ));
3695 }
3696 let remembered_here = match jar.get(REMEMBER_COOKIE) {
3699 Some(cookie) => {
3700 state
3701 .auth
3702 .remember_holder(cookie.value())
3703 .await
3704 .ok()
3705 .flatten()
3706 == Some(user.user.id)
3707 }
3708 None => false,
3709 };
3710 let token = jar
3711 .get(SESSION_COOKIE)
3712 .map(|c| c.value().to_string())
3713 .unwrap_or_default();
3714 let changed = state
3715 .auth
3716 .change_own_password(
3717 user.user.id,
3718 &form.current_password,
3719 &form.new_password,
3720 &token,
3721 &ctx,
3722 )
3723 .await;
3724 match changed {
3725 Ok(()) => {}
3726 Err(laterite_auth::AuthError::InvalidCredentials) => {
3727 return refuse(t!("Your current password is not correct."));
3728 }
3729 Err(laterite_auth::AuthError::TooManyAttempts) => {
3730 return refuse(t!("Too many attempts. Try again later."));
3731 }
3732 Err(e) => {
3733 tracing::error!(error = %e, "changing a password failed");
3734 return refuse(t!("The password could not be changed."));
3735 }
3736 }
3737 session.push_flash_sticky(
3738 FlashLevel::Success,
3739 t!("Password changed. Every other device was signed out."),
3740 );
3741 let jar = if remembered_here {
3742 match state.auth.issue_remember(user.user.id).await {
3743 Ok(credential) => jar.add(remember_cookie(
3744 credential,
3745 &state.admin_path,
3746 state.secure_cookie,
3747 )),
3748 Err(e) => {
3749 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
3750 jar.remove(remember_removal(&state.admin_path))
3751 }
3752 }
3753 } else if jar.get(REMEMBER_COOKIE).is_some() {
3754 jar.remove(remember_removal(&state.admin_path))
3757 } else {
3758 jar
3759 };
3760 (
3761 jar,
3762 Redirect::to(&format!("{}/preferences", state.admin_path)),
3763 )
3764 .into_response()
3765}
3766
3767#[derive(Deserialize)]
3768struct PreferencesForm {
3769 timezone: String,
3770 #[serde(default)]
3772 locale: String,
3773}
3774
3775async fn preferences_update(
3776 State(state): State<AdminState>,
3777 Extension(user): Extension<AuthenticatedUser>,
3778 Extension(session): Extension<session::SessionHandle>,
3779 Form(form): Form<PreferencesForm>,
3780) -> Response {
3781 let back = format!("{}/preferences", state.admin_path);
3782 let offered = offered_locales(&state.catalogs);
3783 let tz = form.timezone.trim();
3785 let tz_stored = if tz.is_empty() {
3786 None
3787 } else if tz.parse::<Tz>().is_ok() {
3788 Some(tz)
3789 } else {
3790 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
3791 return Redirect::to(&back).into_response();
3792 };
3793 let loc = form.locale.trim();
3794 let loc_stored = if loc.is_empty() {
3795 None
3796 } else if offered.iter().any(|l| l == loc) {
3797 Some(loc)
3798 } else {
3799 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
3800 return Redirect::to(&back).into_response();
3801 };
3802 if state
3803 .auth
3804 .set_user_timezone(user.user.id, tz_stored)
3805 .await
3806 .is_err()
3807 {
3808 return render_error();
3809 }
3810 match state.auth.set_user_locale(user.user.id, loc_stored).await {
3811 Ok(()) => {
3812 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
3813 Redirect::to(&back).into_response()
3814 }
3815 Err(_) => render_error(),
3816 }
3817}
3818
3819#[allow(clippy::too_many_arguments)]
3823fn preferences_view(
3824 shell: &Shell,
3825 user: &AuthenticatedUser,
3826 default_tz: Tz,
3827 default_locale: &str,
3828 offered: &[String],
3829 account: AccountSecurity,
3830 min_password: usize,
3831 items: Vec<PreferenceLink>,
3832) -> PreferencesTemplate {
3833 let current = user.user.timezone.as_deref();
3834 let zones = TZ_VARIANTS
3835 .iter()
3836 .filter(|tz| zone_offered(tz.name()))
3837 .map(|tz| TzOption {
3838 name: tz.name().to_string(),
3839 selected: current == Some(tz.name()),
3840 })
3841 .collect();
3842 let current_locale = user.user.locale.as_deref();
3843 let locales = offered
3844 .iter()
3845 .map(|code| LocaleOption {
3846 name: locale_name(code),
3847 selected: current_locale == Some(code.as_str()),
3848 code: code.clone(),
3849 })
3850 .collect();
3851 PreferencesTemplate {
3852 shell: shell.clone(),
3853 zones,
3854 effective_tz: shell.tz.name().to_string(),
3855 default_tz: default_tz.name().to_string(),
3856 inherits: current.is_none(),
3857 locales,
3858 default_locale: locale_name(default_locale),
3859 inherits_locale: current_locale.is_none(),
3860 sessions: account.sessions,
3861 password_changed: account.password_changed,
3862 must_change: account.must_change,
3863 min_password: min_password as i64,
3864 items,
3865 }
3866}
3867
3868struct AccountSecurity {
3871 sessions: Vec<SessionRow>,
3872 password_changed: Option<String>,
3873 must_change: bool,
3875}
3876
3877async fn account_security(
3878 state: &AdminState,
3879 user: &AuthenticatedUser,
3880 shell: &Shell,
3881 jar: &CookieJar,
3882) -> AccountSecurity {
3883 let locale = list::date_locale(shell.locale());
3884 let password_changed = match state.auth.password_changed_at(user.user.id).await {
3885 Ok(at) => {
3886 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
3887 }
3888 Err(e) => {
3889 tracing::error!(error = %e, "reading the password change time failed");
3890 None
3891 }
3892 };
3893 AccountSecurity {
3894 sessions: session_rows(state, user, shell, jar).await,
3895 password_changed,
3896 must_change: state
3897 .auth
3898 .must_change_password(user.user.id)
3899 .await
3900 .unwrap_or(false),
3901 }
3902}
3903
3904async fn session_rows(
3907 state: &AdminState,
3908 user: &AuthenticatedUser,
3909 shell: &Shell,
3910 jar: &CookieJar,
3911) -> Vec<SessionRow> {
3912 let token = jar
3913 .get(SESSION_COOKIE)
3914 .map(|c| c.value().to_string())
3915 .unwrap_or_default();
3916 let locale = list::date_locale(shell.locale());
3917 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
3918 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
3919 };
3920 match state.auth.list_sessions(user.user.id, &token).await {
3921 Ok(sessions) => sessions
3922 .into_iter()
3923 .map(|s| SessionRow {
3924 id: s.id,
3925 started: stamp(s.created_at),
3926 last_seen: stamp(s.last_seen_at),
3927 expires: stamp(s.expires_at),
3928 device: clientip::describe(s.user_agent.as_deref())
3929 .or(s.user_agent)
3930 .unwrap_or_else(|| "-".to_string()),
3931 ip_address: s.ip_address.unwrap_or_default(),
3932 current: s.current,
3933 })
3934 .collect(),
3935 Err(e) => {
3936 tracing::error!(error = %e, "listing sessions failed");
3937 Vec::new()
3938 }
3939 }
3940}
3941
3942fn builtin_resources() -> Vec<Resource> {
3944 vec![
3945 Resource {
3946 base_path: "/users".to_string(),
3947 nav_label: "Administrators".into(),
3948 list: backend_users_list_config(),
3949 form: None,
3950 permission: Some("backend.manage_users".to_string()),
3951 import: None,
3952 palette: None,
3953 relations: Vec::new(),
3954 menu: nav::Placement::default(),
3955 },
3956 Resource {
3957 base_path: "/roles".to_string(),
3958 nav_label: "Roles".into(),
3959 list: roles_list_config(),
3960 form: None,
3963 permission: Some("backend.manage_roles".to_string()),
3964 import: None,
3965 palette: None,
3966 relations: Vec::new(),
3967 menu: nav::Placement::default(),
3968 },
3969 resource!("descriptors/audit-log.yaml"),
3970 resource!("descriptors/access-log.yaml"),
3971 resource!("descriptors/request-log.yaml"),
3972 ]
3973}
3974
3975fn builtin_settings() -> Vec<settings::SettingsItem> {
3979 vec![
3980 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
3981 .description("Manage backend administrator accounts.")
3982 .category("Users")
3983 .order(10)
3984 .icon("users")
3985 .permission("backend.manage_users")
3986 .link("/users"),
3987 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
3988 .description("Manage roles and their permissions.")
3989 .category("Users")
3990 .order(20)
3991 .icon("shield")
3992 .permission("backend.manage_roles")
3993 .link("/roles"),
3994 settings::brand::settings_item(),
3995 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
3996 .description("Enable or disable installed plugins.")
3997 .category("System")
3998 .order(10)
3999 .icon("plug")
4000 .permission(plugins::MANAGE_PERMISSION)
4001 .link("/plugins"),
4002 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
4003 .description("Review the record of administrative changes.")
4004 .category("System")
4005 .order(20)
4006 .icon("history")
4007 .permission("backend.view_audit_log")
4008 .link("/audit-log"),
4009 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
4010 .description("Review sign-ins, failures and lockouts.")
4011 .category("System")
4012 .order(30)
4013 .icon("log-in")
4014 .permission("backend.view_access_log")
4015 .link("/access-log"),
4016 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
4017 .description("Review the URLs the panel could not serve.")
4018 .category("System")
4019 .order(31)
4020 .icon("unlink")
4021 .permission("backend.view_request_log")
4022 .link("/request-log"),
4023 ]
4024}
4025
4026fn backend_users_list_config() -> list::ListConfig {
4027 list::ListConfig {
4028 entity: "backend_users".to_string(),
4029 title: "Administrators".into(),
4030 columns: vec![
4031 list::ListColumn::new("username", "Username"),
4032 list::ListColumn::new("email", "Email"),
4033 list::ListColumn::new("first_name", "First name"),
4034 list::ListColumn::new("last_name", "Last name"),
4035 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
4036 list::ListColumn::new("is_active", "Active").yes_no(),
4037 list::ListColumn::new("created_at", "Created").datetime(),
4038 ],
4039 order_by: "created_at".to_string(),
4040 creatable: true,
4043 edit_base: Some("/users".to_string()),
4044 filters: vec![
4045 list::ListFilter::boolean("is_active", "Active"),
4046 list::ListFilter::boolean("is_superuser", "Superuser"),
4047 ],
4048 ..Default::default()
4052 }
4053}
4054
4055fn roles_list_config() -> list::ListConfig {
4056 list::ListConfig {
4057 entity: "backend_roles".to_string(),
4058 title: "Roles".into(),
4059 columns: vec![
4060 list::ListColumn::new("code", "Code"),
4061 list::ListColumn::new("name", "Name"),
4062 list::ListColumn::new("created_at", "Created").datetime(),
4063 ],
4064 order_by: "created_at".to_string(),
4065 edit_base: Some("/roles".to_string()),
4066 creatable: true,
4067 deletable: true,
4068 ..Default::default()
4069 }
4070}
4071
4072#[derive(Template)]
4073#[template(path = "login.html")]
4074struct LoginTemplate {
4075 base: String,
4077 asset_urls: Arc<AssetUrls>,
4079 brand: String,
4080 error: Option<String>,
4081 notice: Option<String>,
4083 i18n: Translator,
4086}
4087
4088fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
4091 match urls.get(key) {
4092 Some(path) => format!("{base}/assets/{path}"),
4093 None => {
4094 debug_assert!(false, "asset key `{key}` is not registered");
4095 format!("{base}/assets/{key}")
4096 }
4097 }
4098}
4099
4100impl LoginTemplate {
4101 fn asset(&self, key: &str) -> String {
4102 preauth_asset(&self.asset_urls, &self.base, key)
4103 }
4104 fn t(&self, source: &str) -> String {
4105 self.i18n.t(&Text::dynamic(source))
4106 }
4107 fn locale(&self) -> &str {
4108 self.i18n.locale()
4109 }
4110}
4111
4112#[derive(Template)]
4113#[template(path = "dashboard.html")]
4114struct DashboardTemplate {
4115 shell: Shell,
4116 username: String,
4117}
4118
4119#[derive(Template)]
4120#[template(path = "setup.html")]
4121struct SetupTemplate {
4122 base: String,
4124 asset_urls: Arc<AssetUrls>,
4126 brand: String,
4127 zones: Vec<TzOption>,
4128 error: Option<String>,
4129 i18n: Translator,
4131 min_password: i64,
4133}
4134
4135impl SetupTemplate {
4136 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
4139 let mut text = Text::dynamic(source);
4140 for (name, value) in args {
4141 text = text.arg(*name, *value);
4142 }
4143 self.i18n.t(&text)
4144 }
4145
4146 fn asset(&self, key: &str) -> String {
4147 preauth_asset(&self.asset_urls, &self.base, key)
4148 }
4149 fn t(&self, source: &str) -> String {
4150 self.i18n.t(&Text::dynamic(source))
4151 }
4152 fn locale(&self) -> &str {
4153 self.i18n.locale()
4154 }
4155}
4156
4157#[derive(Template)]
4158#[template(path = "preferences.html")]
4159struct PreferencesTemplate {
4160 shell: Shell,
4161 zones: Vec<TzOption>,
4162 effective_tz: String,
4164 default_tz: String,
4166 inherits: bool,
4168 locales: Vec<LocaleOption>,
4169 default_locale: String,
4171 inherits_locale: bool,
4173 sessions: Vec<SessionRow>,
4175 password_changed: Option<String>,
4177 must_change: bool,
4179 min_password: i64,
4181 items: Vec<PreferenceLink>,
4183}
4184
4185struct SessionRow {
4187 id: String,
4188 started: String,
4189 last_seen: String,
4190 expires: String,
4191 device: String,
4194 ip_address: String,
4196 current: bool,
4197}
4198
4199struct TzOption {
4200 name: String,
4201 selected: bool,
4202}
4203
4204struct LocaleOption {
4205 code: String,
4207 name: String,
4209 selected: bool,
4210}
4211
4212#[derive(Clone)]
4214struct CrumbView {
4215 label: String,
4216 href: String,
4217}
4218
4219#[derive(Clone)]
4220struct NavView {
4221 key: String,
4223 label: String,
4224 path: String,
4225 active: bool,
4226 icon: String,
4229}
4230
4231pub(crate) fn render<T: Template>(template: T) -> Response {
4233 match template.render() {
4234 Ok(html) => Html(html).into_response(),
4235 Err(e) => AdminError::from(e).into_response(),
4237 }
4238}
4239
4240pub(crate) fn render_error() -> Response {
4241 error::masked_500()
4242}
4243
4244pub(crate) fn not_found() -> Response {
4245 AdminError::NotFound.into_response()
4246}
4247
4248pub(crate) fn forbidden() -> Response {
4249 AdminError::Forbidden.into_response()
4250}
4251
4252#[cfg(test)]
4253mod tests {
4254 use super::*;
4255
4256 fn write_form() -> form::FormConfig {
4257 form::FormConfig {
4258 enter: Default::default(),
4259 confirm_leave: true,
4260 focus: Default::default(),
4261 entity: "widgets".to_string(),
4262 title: "Widgets".into(),
4263 base_path: "/widgets".to_string(),
4264 id_field: "id".to_string(),
4265 fields: vec![],
4266 persist: None,
4267 timestamps: false,
4268 done_path: None,
4269 }
4270 }
4271
4272 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
4273 Resource {
4274 base_path: "/widgets".to_string(),
4275 nav_label: "Widgets".into(),
4276 list: backend_users_list_config(),
4277 form,
4278 permission: permission.map(str::to_string),
4279 import: None,
4280 palette: None,
4281 relations: Vec::new(),
4282 menu: nav::Placement::default(),
4283 }
4284 }
4285
4286 #[test]
4287 #[should_panic(expected = "must declare a permission")]
4288 fn write_resource_without_permission_aborts_boot() {
4289 let resource = resource_with(Some(write_form()), None);
4292 let _ = mount_resource(
4293 &resource,
4294 &field::builtin_registry(),
4295 &list::builtin_column_registry(),
4296 &std::collections::HashSet::new(),
4297 &persist::PersisterRegistry::new(),
4298 &[],
4299 );
4300 }
4301
4302 #[test]
4304 fn the_access_log_is_read_from_its_descriptor_file() {
4305 let access = builtin_resources()
4306 .into_iter()
4307 .find(|r| r.base_path == "/access-log")
4308 .expect("the access log is registered");
4309 assert_eq!(access.list.entity, "backend_access_log");
4310 assert_eq!(
4311 access.permission.as_deref(),
4312 Some("backend.view_access_log")
4313 );
4314 assert!(access.form.is_none(), "append-only");
4315 assert_eq!(
4316 access
4317 .list
4318 .columns
4319 .iter()
4320 .map(|c| c.field.as_str())
4321 .collect::<Vec<_>>(),
4322 [
4323 "created_at",
4324 "username_attempted",
4325 "event",
4326 "ip_address",
4327 "user_agent"
4328 ]
4329 );
4330 }
4331
4332 #[test]
4335 fn the_audit_log_is_read_from_its_descriptor_file() {
4336 let audit = builtin_resources()
4337 .into_iter()
4338 .find(|r| r.base_path == "/audit-log")
4339 .expect("the audit log is registered");
4340 assert_eq!(audit.list.entity, "backend_audit_log");
4341 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
4342 assert_eq!(
4343 audit
4344 .list
4345 .columns
4346 .iter()
4347 .map(|c| c.field.as_str())
4348 .collect::<Vec<_>>(),
4349 [
4350 "created_at",
4351 "actor_username",
4352 "action",
4353 "target_type",
4354 "target_id",
4355 "target_label"
4356 ],
4357 "in the order the file writes them"
4358 );
4359 assert_eq!(audit.list.columns[0].column_type, "datetime");
4360 assert_eq!(audit.list.per_page, 50);
4361 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
4362 assert!(audit.list.exportable, "evidence may leave the panel");
4363 assert!(audit.form.is_none(), "append-only: no form");
4364 assert!(!audit.list.deletable, "and nothing removes from it");
4365 }
4366
4367 #[test]
4368 #[should_panic(expected = "uses unregistered type `nope`")]
4369 fn unregistered_column_type_aborts_boot() {
4370 let mut resource = resource_with(None, None);
4371 let mut score = list::ListColumn::new("score", "Score");
4372 score.column_type = "nope".into();
4373 resource.list.columns.push(score);
4374 let _ = mount_resource(
4375 &resource,
4376 &field::builtin_registry(),
4377 &list::builtin_column_registry(),
4378 &std::collections::HashSet::new(),
4379 &persist::PersisterRegistry::new(),
4380 &[],
4381 );
4382 }
4383
4384 #[test]
4385 fn read_only_resource_may_omit_permission() {
4386 let resource = resource_with(None, None);
4388 let _ = mount_resource(
4389 &resource,
4390 &field::builtin_registry(),
4391 &list::builtin_column_registry(),
4392 &std::collections::HashSet::new(),
4393 &persist::PersisterRegistry::new(),
4394 &[],
4395 );
4396 }
4397
4398 #[test]
4399 fn shell_localizes_through_the_translator_and_falls_back() {
4400 let shell = Shell::test();
4402 assert_eq!(shell.t("Save"), "Save");
4403 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
4404 }
4405
4406 #[test]
4407 fn shell_tf_interpolates_integer_arguments() {
4408 let shell = Shell::test();
4409 assert_eq!(
4410 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
4411 "Page 2 of 5"
4412 );
4413 }
4414
4415 #[test]
4416 fn display_tz_prefers_a_valid_operator_preference() {
4417 assert_eq!(
4418 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
4419 Tz::Asia__Kolkata
4420 );
4421 }
4422
4423 #[test]
4424 fn display_tz_falls_back_when_unset_or_invalid() {
4425 let default = Tz::Europe__London;
4426 assert_eq!(resolve_display_tz(None, default), default);
4428 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
4430 assert_eq!(resolve_display_tz(Some(""), default), default);
4431 }
4432
4433 #[test]
4434 fn accept_language_orders_by_weight_and_drops_wildcard() {
4435 assert_eq!(
4436 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
4437 vec!["en-US", "en", "kn"]
4438 );
4439 assert_eq!(
4441 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
4442 vec!["kn", "en"]
4443 );
4444 }
4445
4446 #[test]
4447 fn locale_chain_prefers_operator_then_header_then_default() {
4448 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
4450 assert_eq!(
4453 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
4454 vec!["kn", "ta", "hi", "en"]
4455 );
4456 assert_eq!(
4458 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
4459 vec!["kn", "en", "ta"]
4460 );
4461 assert_eq!(
4463 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
4464 vec!["kn", "en"]
4465 );
4466 }
4467
4468 #[test]
4469 fn locale_chain_skips_unserveable_and_defaults_to_en() {
4470 let loaded = ["hi".to_string()];
4472 assert_eq!(
4475 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
4476 vec!["en"]
4477 );
4478 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
4480 assert_eq!(
4482 resolve_locale_chain(Some("hi"), None, "en", &loaded),
4483 vec!["hi", "en"]
4484 );
4485 assert_eq!(
4487 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
4488 vec![laterite_core::PSEUDO_LOCALE, "en"]
4489 );
4490 }
4491
4492 #[test]
4493 fn page_assets_dedupe_preserve_order_and_classify() {
4494 let mut reg = AssetRegistry::new();
4495 reg.insert(
4496 "a.js",
4497 AdminAsset {
4498 mime: "text/javascript; charset=utf-8",
4499 bytes: std::borrow::Cow::Borrowed(b"//"),
4500 },
4501 );
4502 reg.insert(
4503 "b.css",
4504 AdminAsset {
4505 mime: "text/css; charset=utf-8",
4506 bytes: std::borrow::Cow::Borrowed(b"body{}"),
4507 },
4508 );
4509 let urls = asset_urls(®);
4512 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
4513 assert_eq!(assets.len(), 2);
4514 assert_eq!(
4515 assets[0].url,
4516 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
4517 );
4518 assert!(!assets[0].css);
4519 assert_eq!(
4520 assets[1].url,
4521 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
4522 );
4523 assert!(assets[1].css);
4524 assert_ne!(assets[0].url, assets[1].url);
4527 }
4528
4529 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
4533 laterite_core::testing::connect_test(&[]).await
4534 }
4535
4536 #[tokio::test]
4537 async fn builtin_migrations_create_the_admin_tables() {
4538 let (db, _guard) = empty_db().await;
4539 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
4540 .await
4541 .unwrap();
4542 for table in ["backend_users", "settings"] {
4546 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
4547 .fetch_optional(&db.pool)
4548 .await;
4549 assert!(
4550 probe.is_ok(),
4551 "{table} should exist after builtin_migrations"
4552 );
4553 }
4554 }
4555
4556 #[tokio::test]
4557 async fn brand_setting_overrides_config_and_blank_falls_back() {
4558 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
4559 let state = AdminState {
4560 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
4561 db: db.clone(),
4562 nav: Arc::new(nav::NavTree::default()),
4563 settings: Arc::new(Vec::new()),
4564 permissions: Arc::new(builtin_permissions()),
4565 trusted_proxies: Arc::new(Vec::new()),
4566 icons: Arc::new(laterite_core::icons::IconSet::new()),
4567 sprite_url: Arc::from(""),
4568 admin_path: Arc::from("/admin"),
4569 secure_cookie: false,
4570 origin: Arc::from(""),
4571 timezone: Tz::UTC,
4572 default_locale: Arc::from("en"),
4573 catalogs: Arc::new(CatalogStore::default()),
4574 app_name: "Configured Name".to_string(),
4575 brand_cache: Arc::new(RwLock::new(None)),
4576 field_types: Arc::new(field::builtin_registry()),
4577 column_types: Arc::new(list::builtin_column_registry()),
4578 plugin_defined: Arc::new(laterite_core::Registry::new()),
4579 assets: Arc::new(builtin_assets()),
4580 asset_urls: Arc::new(asset_urls(&builtin_assets())),
4581 pickers: Arc::new(picker::PickerRegistry::new()),
4582 palette: Arc::new(Vec::new()),
4583 overrides: Arc::new(field::NoOverrides),
4584 };
4585
4586 assert_eq!(state.brand().await, "Configured Name");
4588
4589 settings::store::save(
4592 &db,
4593 &settings::BrandSetting {
4594 app_name: "Acme Corp".to_string(),
4595 },
4596 )
4597 .await
4598 .unwrap();
4599 state.invalidate_brand();
4600 assert_eq!(state.brand().await, "Acme Corp");
4601
4602 settings::store::save(
4604 &db,
4605 &settings::BrandSetting {
4606 app_name: " ".to_string(),
4607 },
4608 )
4609 .await
4610 .unwrap();
4611 state.invalidate_brand();
4612 assert_eq!(state.brand().await, "Configured Name");
4613 }
4614
4615 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
4616 settings::SettingsItem {
4617 code: code.to_string(),
4618 label: code.into(),
4619 description: String::new().into(),
4620 category: "General".into(),
4621 hint: None,
4622 confirm_leave: true,
4623 order: 1,
4624 icon: None,
4625 permission: permission.map(str::to_string),
4626 link: None,
4627 fields: Vec::new(),
4628 route: None,
4629 per_operator: false,
4630 }
4631 }
4632
4633 #[test]
4634 fn settings_visibility_respects_permissions() {
4635 let items = vec![
4636 settings_item("public", None),
4637 settings_item("gated", Some("backend.manage_users")),
4638 ];
4639
4640 let none = PermissionSet::new(false, Vec::<String>::new());
4642 let codes: Vec<String> = visible_settings(&items, &none)
4643 .into_iter()
4644 .map(|i| i.code)
4645 .collect();
4646 assert_eq!(codes, ["public"]);
4647
4648 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
4650 assert_eq!(visible_settings(&items, &granted).len(), 2);
4651
4652 let superuser = PermissionSet::new(true, Vec::<String>::new());
4654 assert_eq!(visible_settings(&items, &superuser).len(), 2);
4655 }
4656
4657 #[test]
4658 fn context_sidebar_follows_settings_links() {
4659 let items = builtin_settings();
4660 let superuser = PermissionSet::new(true, Vec::<String>::new());
4661 let tr = Translator::new("en");
4662 let tree = nav::NavTree::default();
4663 let sidebar = |path: &str| {
4664 resolve_nav_context(
4665 &tree,
4666 &items,
4667 "/admin",
4668 &superuser,
4669 None,
4670 path,
4671 &tr,
4672 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
4673 )
4674 .1
4675 };
4676 let active_path = |path: &str| -> Option<String> {
4677 sidebar(path)
4678 .into_iter()
4679 .flat_map(|g| g.items)
4680 .find(|i| i.active)
4681 .map(|i| i.path)
4682 };
4683
4684 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
4686 assert_eq!(
4687 active_path("/admin/roles/42/edit").as_deref(),
4688 Some("/admin/roles")
4689 );
4690 assert!(!sidebar("/admin/settings").is_empty());
4692 assert_eq!(active_path("/admin/settings"), None);
4693 assert_eq!(
4698 active_path("/admin/settings/branding").as_deref(),
4699 Some("/admin/settings/branding")
4700 );
4701 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
4704 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
4705 assert!(sidebar("/admin").is_empty());
4707 }
4708}
4709
4710#[cfg(test)]
4711mod session_cookie_tests {
4712 use super::*;
4713
4714 #[test]
4718 fn the_session_cookie_never_outlives_the_browser() {
4719 let cookie = session_cookie("tok".into(), "/admin", false);
4720 assert_eq!(cookie.max_age(), None);
4721 assert!(cookie.http_only().unwrap_or(false));
4722 assert_eq!(cookie.path(), Some("/admin"));
4723 }
4724
4725 #[test]
4728 fn the_remember_cookie_carries_its_own_lifetime() {
4729 let credential = laterite_auth::RememberCredential {
4730 cookie: "sel:ver".into(),
4731 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
4732 };
4733 let cookie = remember_cookie(credential, "/admin", true);
4734 let age = cookie.max_age().expect("a remember cookie must persist");
4735 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
4737 assert!(cookie.http_only().unwrap_or(false));
4738 assert!(cookie.secure().unwrap_or(false));
4739 }
4740
4741 #[test]
4743 fn the_removal_matches_the_cookie_it_clears() {
4744 let removal = remember_removal("/admin");
4745 assert_eq!(removal.name(), REMEMBER_COOKIE);
4746 assert_eq!(removal.path(), Some("/admin"));
4747 }
4748}