1mod audit;
18pub mod bootstrap;
19mod bulk;
20mod clientip;
21mod error;
22mod export;
23pub mod field;
24pub mod form;
25pub mod html;
26pub mod http_cache;
27mod icons;
28pub mod list;
29pub mod persist;
30pub mod picker;
31pub mod plugins;
32mod roles;
33mod routemap;
34pub mod routes;
35mod session;
36pub mod settings;
37mod sql;
38mod upload;
39mod users;
40
41pub use axum;
49
50pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
51pub use error::AdminError;
52pub use session::{FlashLevel, SessionHandle};
53pub use upload::VerifiedUpload;
54
55use std::collections::HashMap;
56use std::sync::{Arc, RwLock};
57
58use askama::Template;
59use axum::body::Body;
60use axum::extract::{Path, Query, Request, State};
61use axum::http::{header, HeaderMap};
62use axum::middleware::{self, Next};
63use axum::response::{Html, IntoResponse, Redirect, Response};
64use axum::routing::{any, get, post};
65use axum::{Extension, Form, Router};
66use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
67use chrono_tz::{Tz, TZ_VARIANTS};
68use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
69use laterite_core::{t, CatalogStore, Db, Text, Translator};
70use serde::{Deserialize, Serialize};
71
72pub trait AdminRegistry {
78 fn add_resource(&mut self, resource: Resource);
80 fn add_permission(&mut self, permission: Permission);
82 fn add_settings(&mut self, item: settings::SettingsItem);
84 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
86 fn add_persister(&mut self, persister: persist::PersisterReg);
88 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
90 fn add_screen(&mut self, screen: routes::ScreenReg);
93 fn add_public_route(&mut self, route: routes::PublicRouteReg);
96}
97
98impl AdminRegistry for laterite_core::Registry {
99 fn add_resource(&mut self, resource: Resource) {
100 self.add(resource);
101 }
102 fn add_permission(&mut self, permission: Permission) {
103 self.add(permission);
104 }
105 fn add_settings(&mut self, item: settings::SettingsItem) {
106 self.add(item);
107 }
108 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
109 self.add(source);
110 }
111 fn add_screen(&mut self, screen: routes::ScreenReg) {
112 self.add(screen);
113 }
114 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
115 self.add(route);
116 }
117 fn add_persister(&mut self, persister: persist::PersisterReg) {
118 self.add(persister);
119 }
120 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
121 self.add(field_type);
122 }
123}
124
125const SESSION_COOKIE: &str = "laterite_session";
126const REMEMBER_COOKIE: &str = "laterite_remember";
130
131pub(crate) const ICON_SPRITE: &str = "icons.svg";
134
135#[derive(Clone)]
137pub(crate) struct AdminState {
138 auth: AuthService,
139 db: Db,
140 nav: Arc<Vec<NavLink>>,
141 settings: Arc<Vec<settings::SettingsItem>>,
142 permissions: Arc<Vec<Permission>>,
143 admin_path: Arc<str>,
147 secure_cookie: bool,
148 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
150 icons: Arc<laterite_core::icons::IconSet>,
152 sprite_url: Arc<str>,
154 origin: Arc<str>,
156 timezone: Tz,
157 default_locale: Arc<str>,
161 catalogs: Arc<CatalogStore>,
164 app_name: String,
167 brand_cache: Arc<RwLock<Option<String>>>,
171 asset_urls: Arc<AssetUrls>,
173 field_types: Arc<field::FieldRegistry>,
176 column_types: Arc<list::ColumnRegistry>,
179 plugin_defined: Arc<laterite_core::Registry>,
182 assets: Arc<AssetRegistry>,
184 pickers: Arc<picker::PickerRegistry>,
187 overrides: Arc<dyn field::OverrideResolver>,
191}
192
193impl AdminState {
194 #[cfg(test)]
195 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
196 Self {
197 auth,
198 db,
199 nav: Arc::new(Vec::new()),
200 settings: Arc::new(Vec::new()),
201 permissions: Arc::new(builtin_permissions()),
202 trusted_proxies: Arc::new(Vec::new()),
203 icons: Arc::new(laterite_core::icons::IconSet::new()),
204 sprite_url: Arc::from(""),
205 admin_path: Arc::from("/admin"),
206 secure_cookie: false,
207 origin: Arc::from(""),
208 timezone: Tz::UTC,
209 default_locale: Arc::from("en"),
210 catalogs: Arc::new(CatalogStore::default()),
211 app_name: "Laterite".to_string(),
212 brand_cache: Arc::new(RwLock::new(None)),
213 field_types: Arc::new(field::builtin_registry()),
214 column_types: Arc::new(list::builtin_column_registry()),
215 plugin_defined: Arc::new(laterite_core::Registry::new()),
216 assets: Arc::new(builtin_assets()),
217 asset_urls: Arc::new(asset_urls(&builtin_assets())),
218 pickers: Arc::new(picker::PickerRegistry::new()),
219 overrides: Arc::new(field::NoOverrides),
220 }
221 }
222
223 async fn brand(&self) -> String {
227 {
228 let cached = self.brand_cache.read().unwrap().clone();
229 if let Some(name) = cached {
230 return name;
231 }
232 }
233 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
234 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
235 _ => self.app_name.clone(),
236 };
237 *self.brand_cache.write().unwrap() = Some(resolved.clone());
238 resolved
239 }
240
241 fn invalidate_brand(&self) {
243 *self.brand_cache.write().unwrap() = None;
244 }
245}
246
247#[derive(Clone)]
253#[non_exhaustive]
254pub struct AdminConfig {
255 pub secure_cookie: bool,
258 pub trusted_proxies: Vec<String>,
262 pub timezone: String,
266 pub locale: String,
270 pub app_name: String,
274 pub path: String,
278 pub origin: String,
283}
284
285impl Default for AdminConfig {
286 fn default() -> Self {
287 Self {
288 secure_cookie: false,
289 trusted_proxies: Vec::new(),
290 timezone: "UTC".to_string(),
291 locale: "en".to_string(),
292 app_name: "Laterite".to_string(),
293 path: "/admin".to_string(),
294 origin: String::new(),
295 }
296 }
297}
298
299#[derive(Clone)]
300struct NavLink {
301 label: Text,
302 path: String,
303 icon: Option<&'static str>,
306}
307
308#[derive(Clone)]
313pub(crate) struct Shell {
314 pub(crate) base: String,
318 brand: String,
322 nav: Vec<NavView>,
323 full_name: String,
324 initial: String,
325 tz: Tz,
329 i18n: Translator,
333 sidebar: Vec<settings::CategoryView>,
337 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
339 pub(crate) sprite_url: Arc<str>,
342 site_url: String,
346 asset_urls: Arc<AssetUrls>,
350 pub(crate) csrf_token: String,
354 pub(crate) flash: Vec<FlashLine>,
358 pub(crate) assets: Vec<PageAsset>,
362}
363
364#[derive(Clone)]
366pub(crate) struct PageAsset {
367 pub(crate) url: String,
368 pub(crate) css: bool,
369}
370
371#[derive(Clone)]
374pub(crate) struct FlashLine {
375 pub(crate) level: session::FlashLevel,
376 pub(crate) text: String,
377}
378
379impl Shell {
380 #[allow(clippy::too_many_arguments)]
381 fn new(
382 base: &str,
383 brand: String,
384 nav: &[NavLink],
385 user: &AuthenticatedUser,
386 default_tz: Tz,
387 sidebar: Vec<settings::CategoryView>,
388 active_nav: Option<&str>,
389 csrf_token: String,
390 flash: Vec<session::Flash>,
391 i18n: Translator,
392 asset_urls: Arc<AssetUrls>,
393 site_url: String,
394 icon_set: Arc<laterite_core::icons::IconSet>,
395 sprite_url: Arc<str>,
396 ) -> Self {
397 let full_name = user.user.full_name();
398 let initial = full_name
399 .chars()
400 .next()
401 .map(|c| c.to_uppercase().to_string())
402 .unwrap_or_else(|| "?".to_string());
403 let flash = flash
406 .into_iter()
407 .map(|f| FlashLine {
408 level: f.level,
409 text: i18n.t(&f.text),
410 })
411 .collect();
412 let nav = nav
413 .iter()
414 .map(|n| NavView {
415 label: i18n.t(&n.label),
416 path: n.path.clone(),
417 active: active_nav == Some(n.path.as_str()),
418 icon: n
419 .icon
420 .map(|name| icons::svg(&icon_set, &sprite_url, Some(name)))
421 .unwrap_or_default(),
422 })
423 .collect();
424 Shell {
425 asset_urls,
426 icon_set,
427 sprite_url,
428 site_url,
429 base: base.to_string(),
430 brand,
431 nav,
432 full_name,
433 initial,
434 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
435 i18n,
436 sidebar,
437 csrf_token,
438 flash,
439 assets: Vec::new(),
440 }
441 }
442
443 pub(crate) fn site_url(&self) -> &str {
445 &self.site_url
446 }
447
448 pub(crate) fn icon(&self, name: &str) -> String {
450 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
451 }
452
453 pub(crate) fn asset(&self, key: &str) -> String {
458 match self.asset_urls.get(key) {
459 Some(path) => format!("{}/assets/{path}", self.base),
460 None => {
461 debug_assert!(false, "asset key `{key}` is not registered");
462 format!("{}/assets/{key}", self.base)
463 }
464 }
465 }
466
467 pub(crate) fn t(&self, source: &str) -> String {
470 self.i18n.t(&Text::dynamic(source))
471 }
472
473 pub(crate) fn tt(&self, text: &Text) -> String {
476 self.i18n.t(text)
477 }
478
479 pub(crate) fn i18n(&self) -> &Translator {
482 &self.i18n
483 }
484
485 pub(crate) fn locale(&self) -> &str {
487 self.i18n.locale()
488 }
489
490 pub(crate) fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
494 let mut text = Text::dynamic(source);
495 for (name, value) in args {
496 text = text.arg(*name, *value);
497 }
498 self.i18n.t(&text)
499 }
500
501 pub(crate) fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
504 let mut text = Text::dynamic(source);
505 for (name, value) in args {
506 text = text.arg(*name, *value);
507 }
508 self.i18n.t(&text)
509 }
510
511 #[cfg(test)]
512 pub(crate) fn test() -> Self {
513 Shell {
514 asset_urls: Arc::new(asset_urls(&builtin_assets())),
515 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
516 sprite_url: Arc::from("/admin/assets/icons.svg"),
517 site_url: "http://localhost".to_string(),
518 base: "/admin".to_string(),
519 brand: "Laterite".to_string(),
520 nav: Vec::new(),
521 full_name: "Test Operator".to_string(),
522 initial: "T".to_string(),
523 tz: Tz::UTC,
524 i18n: Translator::new("en"),
525 sidebar: Vec::new(),
526 csrf_token: "test-csrf-token".to_string(),
527 flash: Vec::new(),
528 assets: Vec::new(),
529 }
530 }
531}
532
533fn settings_context(
540 visible: &[settings::SettingsItem],
541 admin_path: &str,
542 path: &str,
543) -> (bool, Option<String>) {
544 if path == format!("{admin_path}/settings") {
545 return (true, None);
546 }
547 let own = visible
553 .iter()
554 .filter(|i| i.link.is_none())
555 .find(|i| path == format!("{admin_path}{}", i.route()))
556 .map(|i| i.code.clone());
557 if own.is_some() {
558 return (true, own);
559 }
560 let linked = visible
561 .iter()
562 .filter_map(|i| {
563 i.link
564 .as_deref()
565 .map(|link| (format!("{admin_path}{link}"), &i.code))
566 })
567 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
568 .max_by_key(|(link, _)| link.len())
569 .map(|(_, code)| code.clone());
570 (linked.is_some(), linked)
571}
572
573fn active_nav_path(
581 nav: &[NavLink],
582 admin_path: &str,
583 in_settings_context: bool,
584 path: &str,
585) -> Option<String> {
586 if in_settings_context {
587 return Some(format!("{admin_path}/settings"));
588 }
589 nav.iter()
590 .filter(|n| {
591 path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
592 })
593 .max_by_key(|n| n.path.len())
594 .map(|n| n.path.clone())
595}
596
597fn resolve_nav_context(
602 nav: &[NavLink],
603 items: &[settings::SettingsItem],
604 admin_path: &str,
605 perms: &PermissionSet,
606 path: &str,
607 tr: &Translator,
608 icons: icons::Icons<'_>,
609) -> (Vec<settings::CategoryView>, Option<String>) {
610 let visible = visible_settings(items, perms);
611 let (in_context, active) = settings_context(&visible, admin_path, path);
612 let sidebar = if in_context {
613 settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons)
614 } else {
615 Vec::new()
616 };
617 let active_nav = active_nav_path(nav, admin_path, in_context, path);
618 (sidebar, active_nav)
619}
620
621fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
625 preference
626 .and_then(|name| name.parse::<Tz>().ok())
627 .unwrap_or(default_tz)
628}
629
630fn zone_offered(name: &str) -> bool {
632 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
633}
634
635fn base_lang(tag: &str) -> String {
638 tag.split(['-', '_'])
639 .next()
640 .unwrap_or(tag)
641 .to_ascii_lowercase()
642}
643
644fn is_serveable(base: &str, serveable: &[String]) -> bool {
650 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
651}
652
653fn default_locale(configured: &str, serveable: &[String]) -> String {
656 let base = base_lang(configured);
657 if is_serveable(&base, serveable) {
658 base
659 } else {
660 "en".to_string()
661 }
662}
663
664fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
668 let mut out = vec!["en".to_string()];
669 out.extend(catalogs.locales());
670 out
671}
672
673fn locale_name(code: &str) -> String {
678 if code == "en" {
679 "English".to_string()
680 } else {
681 code.to_string()
682 }
683}
684
685fn parse_accept_language(header: &str) -> Vec<String> {
688 let mut items: Vec<(f32, String)> = header
689 .split(',')
690 .filter_map(|part| {
691 let mut bits = part.split(';');
692 let tag = bits.next()?.trim();
693 if tag.is_empty() || tag == "*" {
694 return None;
695 }
696 let q = bits
697 .find_map(|p| p.trim().strip_prefix("q="))
698 .and_then(|q| q.parse::<f32>().ok())
699 .unwrap_or(1.0);
700 Some((q, tag.to_string()))
701 })
702 .collect();
703 items.sort_by(|a, b| b.0.total_cmp(&a.0));
705 items.into_iter().map(|(_, tag)| tag).collect()
706}
707
708fn resolve_locale_chain(
714 preference: Option<&str>,
715 accept_language: Option<&str>,
716 default: &str,
717 serveable: &[String],
718) -> Vec<String> {
719 let mut chain: Vec<String> = Vec::new();
720 let consider = |tag: &str, chain: &mut Vec<String>| {
721 let base = base_lang(tag);
722 if is_serveable(&base, serveable) && !chain.contains(&base) {
723 chain.push(base);
724 }
725 };
726 if let Some(pref) = preference {
727 consider(pref, &mut chain);
728 }
729 if let Some(header) = accept_language {
730 for tag in parse_accept_language(header) {
731 consider(&tag, &mut chain);
732 }
733 }
734 consider(default, &mut chain);
735 consider("en", &mut chain);
738 chain
739}
740
741fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
745 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
746 let serveable = state.catalogs.locales();
747 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
748 Translator::with_chain(chain, state.catalogs.clone())
749}
750
751#[derive(Serialize)]
759#[non_exhaustive]
760pub struct Resource {
761 pub base_path: String,
767 pub nav_label: Text,
769 pub list: list::ListConfig,
770 pub form: Option<form::FormConfig>,
771 pub permission: Option<String>,
778}
779
780#[derive(Clone, Serialize)]
785pub struct Permission {
786 pub code: String,
787 pub label: Text,
789 pub group: Text,
790}
791
792impl Resource {
793 pub fn new(
798 base_path: impl Into<String>,
799 nav_label: impl Into<Text>,
800 list: list::ListConfig,
801 ) -> Self {
802 Self {
803 base_path: base_path.into(),
804 nav_label: nav_label.into(),
805 list,
806 form: None,
807 permission: None,
808 }
809 }
810
811 pub fn form(mut self, form: form::FormConfig) -> Self {
814 self.form = Some(form);
815 self
816 }
817
818 pub fn permission(mut self, permission: impl Into<String>) -> Self {
820 self.permission = Some(permission.into());
821 self
822 }
823}
824
825fn builtin_permissions() -> Vec<Permission> {
828 vec![
829 Permission {
830 code: "backend.manage_users".to_string(),
831 label: "Manage backend users".into(),
832 group: "Backend".into(),
833 },
834 Permission {
835 code: "backend.manage_roles".to_string(),
836 label: "Manage roles".into(),
837 group: "Backend".into(),
838 },
839 Permission {
840 code: "backend.manage_branding".to_string(),
841 label: "Manage branding".into(),
842 group: "Backend".into(),
843 },
844 Permission {
845 code: plugins::MANAGE_PERMISSION.to_string(),
846 label: "Manage plugins".into(),
847 group: "Backend".into(),
848 },
849 Permission {
850 code: "backend.view_audit_log".to_string(),
851 label: "View the audit log".into(),
852 group: "Backend".into(),
853 },
854 ]
855}
856
857pub fn descriptor_sources() -> Vec<String> {
863 let mut out = Vec::new();
864 out.extend(laterite_core::collect_sources(&builtin_resources()));
865 out.extend(laterite_core::collect_sources(&builtin_settings()));
866 out.extend(laterite_core::collect_sources(&builtin_permissions()));
867 out.sort();
868 out.dedup();
869 out
870}
871
872pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
887 builtin_modules().iter().map(|m| m.migrations()).collect()
888}
889
890pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
894 vec![
895 Box::new(laterite_auth::AuthModule),
896 Box::new(settings::SettingsModule),
897 Box::new(plugins::PluginsModule),
898 ]
899}
900
901#[derive(Default)]
925pub struct Contributions {
926 pub resources: Vec<Resource>,
928 pub settings: Vec<settings::SettingsItem>,
930 pub permissions: Vec<Permission>,
932 pub picker_sources: Vec<picker::PickerSourceReg>,
934 pub persisters: Vec<persist::PersisterReg>,
936 pub listeners: Vec<laterite_core::ModelListenerReg>,
938 pub field_types: Vec<field::FieldTypeReg>,
942 pub column_types: Vec<list::ColumnTypeReg>,
943 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
946 pub screens: Vec<routes::ScreenReg>,
948 pub public_routes: Vec<routes::PublicRouteReg>,
950 pub plugin_defined: Arc<laterite_core::Registry>,
955}
956
957pub fn router(
958 auth: AuthService,
959 db: Db,
960 contributions: Contributions,
961 config: AdminConfig,
962 catalogs: Arc<CatalogStore>,
963) -> Router {
964 let Contributions {
965 resources: app_resources,
966 settings: app_settings,
967 permissions: app_permissions,
968 picker_sources: app_picker_sources,
969 persisters: app_persisters,
970 listeners: app_listeners,
971 field_types: app_field_types,
972 column_types: app_column_types,
973 icons: app_icons,
974 screens: app_screens,
975 public_routes: app_public,
976 plugin_defined,
977 } = contributions;
978 let admin_path = normalize_path(&config.path);
979
980 let mut resources = builtin_resources();
981 let mut app_resources = app_resources;
982 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
986 prefix_resource(&admin_path, resource);
987 }
988
989 let mut settings = builtin_settings();
990 settings.extend(app_settings);
991 let mut permissions = builtin_permissions();
992 permissions.extend(app_permissions);
993
994 let mut nav = vec![NavLink {
998 label: "Dashboard".into(),
999 path: admin_path.clone(),
1000 icon: Some("layout-dashboard"),
1001 }];
1002 for resource in &app_resources {
1003 nav.push(NavLink {
1004 label: resource.nav_label.clone(),
1005 path: resource.base_path.clone(),
1006 icon: None,
1007 });
1008 }
1009 for reg in &app_screens {
1012 if let Some(label) = ®.nav_label {
1013 nav.push(NavLink {
1014 label: label.clone(),
1015 path: format!("{admin_path}{}", reg.base_path),
1016 icon: None,
1017 });
1018 }
1019 }
1020 nav.push(NavLink {
1021 label: "Settings".into(),
1022 path: format!("{admin_path}/settings"),
1023 icon: Some("settings"),
1024 });
1025 resources.extend(app_resources);
1026
1027 let app_name = if config.app_name.trim().is_empty() {
1028 "Laterite".to_string()
1029 } else {
1030 config.app_name.clone()
1031 };
1032
1033 let mut pickers = picker::PickerRegistry::new();
1036 for reg in app_picker_sources {
1037 let name = reg.name.clone();
1038 if !field::is_name(&name, true) {
1039 panic!("invalid picker source name `{name}`");
1040 }
1041 if pickers.insert(name.clone(), reg).is_some() {
1042 panic!("duplicate picker source `{name}`");
1043 }
1044 }
1045
1046 let pickers = Arc::new(pickers);
1050 let mut field_types = field::builtin_registry();
1051 field_types.insert(
1052 "reference".to_string(),
1053 Arc::new(field::RefPickerField::new(pickers.clone())),
1054 );
1055
1056 let mut persisters = persist::PersisterRegistry::new();
1059 for reg in app_persisters {
1060 let name = reg.name.clone();
1061 if !field::is_name(&name, true) {
1062 panic!("invalid persister name `{name}`");
1063 }
1064 if persisters.insert(name.clone(), reg.persister).is_some() {
1065 panic!("duplicate persister `{name}`");
1066 }
1067 }
1068
1069 for reg in app_field_types {
1075 let name = reg.name().to_string();
1076 if !field::is_name(&name, true) {
1077 panic!("invalid field type name `{name}`");
1078 }
1079 if field_types.insert(name.clone(), reg.field_type).is_some() {
1080 panic!("duplicate field type `{name}`");
1081 }
1082 }
1083
1084 let mut icon_set = laterite_core::icons::IconSet::new();
1088 for (owner, reg) in app_icons {
1089 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1090 panic!("module `{owner}` contributed an invalid icon: {e}");
1091 }
1092 }
1093
1094 let mut assets = builtin_assets();
1097 assets.insert(
1098 ICON_SPRITE,
1099 AdminAsset {
1100 mime: "image/svg+xml",
1101 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1102 },
1103 );
1104
1105 for item in &settings {
1109 icons::require(
1110 &icon_set,
1111 &format!("settings item `{}`", item.code),
1112 item.icon.as_deref(),
1113 );
1114 }
1115 for link in &nav {
1116 icons::require(&icon_set, &format!("menu entry `{}`", link.path), link.icon);
1117 }
1118
1119 let mut column_types = list::builtin_column_registry();
1123 for reg in app_column_types {
1124 let name = reg.name().to_string();
1125 if !field::is_name(&name, true) {
1126 panic!("invalid column type name `{name}`");
1127 }
1128 if column_types.insert(name.clone(), reg.column_type).is_some() {
1129 panic!("duplicate column type `{name}`");
1130 }
1131 }
1132
1133 let settings = Arc::new(settings);
1134 let asset_urls_arc = Arc::new(asset_urls(&assets));
1136 let sprite_url: Arc<str> = Arc::from(
1137 asset_urls_arc
1138 .get(ICON_SPRITE)
1139 .map(|p| format!("{admin_path}/assets/{p}"))
1140 .unwrap_or_default()
1141 .as_str(),
1142 );
1143 let icons_arc = Arc::new(icon_set);
1144 let state = AdminState {
1145 auth,
1146 db,
1147 nav: Arc::new(nav),
1148 settings: settings.clone(),
1149 permissions: Arc::new(permissions),
1150 admin_path: Arc::from(admin_path.as_str()),
1151 secure_cookie: config.secure_cookie,
1152 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1153 icons: icons_arc.clone(),
1154 sprite_url: sprite_url.clone(),
1155 origin: Arc::from(config.origin.trim_end_matches('/')),
1156 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1157 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1158 catalogs,
1159 app_name,
1160 brand_cache: Arc::new(RwLock::new(None)),
1161 field_types: Arc::new(field_types),
1162 column_types: Arc::new(column_types),
1163 plugin_defined,
1164 assets: Arc::new(assets),
1165 asset_urls: asset_urls_arc.clone(),
1166 pickers,
1167 overrides: Arc::new(field::NoOverrides),
1168 };
1169
1170 let mut protected = Router::new().route(&admin_path, get(dashboard));
1171 for resource in &resources {
1172 protected = protected.merge(mount_resource(
1173 resource,
1174 &state.field_types,
1175 &persisters,
1176 &app_listeners,
1177 ));
1178 }
1179 for reg in &app_screens {
1183 let base = format!("{admin_path}{}", reg.base_path);
1184 let ctx = routes::RouteCtx::new(
1185 state.db.clone(),
1186 &base,
1187 &admin_path,
1188 &state.origin,
1189 state.plugin_defined.clone(),
1190 );
1191 protected = protected.merge(guard_with_permission(
1192 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1193 ®.permission,
1194 ));
1195 }
1196
1197 protected = protected.merge(guard_with_permission(
1200 Router::new()
1201 .route(
1202 &format!("{admin_path}/roles/new"),
1203 get(roles::new_form).post(roles::create),
1204 )
1205 .route(
1206 &format!("{admin_path}/roles/{{id}}/edit"),
1207 get(roles::edit_form).post(roles::update),
1208 ),
1209 "backend.manage_roles",
1210 ));
1211 protected = protected.merge(guard_with_permission(
1214 Router::new()
1215 .route(
1216 &format!("{admin_path}/users/{{id}}/edit"),
1217 get(users::edit_form).post(users::update),
1218 )
1219 .route(
1220 &format!("{admin_path}/users/{{id}}/active"),
1221 post(users::set_active),
1222 ),
1223 "backend.manage_users",
1224 ));
1225 protected = protected.merge(guard_with_permission(
1228 Router::new()
1229 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1230 .route(
1231 &format!("{admin_path}/plugins/toggle"),
1232 post(plugins::toggle),
1233 ),
1234 plugins::MANAGE_PERMISSION,
1235 ));
1236 protected = protected
1237 .route(&format!("{admin_path}/settings"), get(settings_index))
1238 .route(
1239 &format!("{admin_path}/preferences"),
1240 get(preferences_form).post(preferences_update),
1241 )
1242 .route(
1243 &format!("{admin_path}/preferences/sessions/revoke"),
1244 post(session_revoke),
1245 )
1246 .route(
1247 &format!("{admin_path}/preferences/sessions/revoke-others"),
1248 post(session_revoke_others),
1249 )
1250 .route(
1253 &format!("{admin_path}/pickers/{{source}}/search"),
1254 any(picker::search),
1255 )
1256 .route(
1257 &format!("{admin_path}/pickers/{{source}}/resolve"),
1258 any(picker::resolve),
1259 )
1260 .route(&format!("{admin_path}/logout"), post(logout));
1261
1262 for item in settings.iter().filter(|i| i.link.is_none()) {
1266 let path = format!("{admin_path}{}", item.route());
1267 let code = item.code.clone();
1268 let edit_code = code.clone();
1269 protected = protected.route(
1270 &path,
1271 get(
1272 move |State(state): State<AdminState>,
1273 Extension(shell): Extension<Shell>,
1274 Extension(user): Extension<AuthenticatedUser>| {
1275 let code = edit_code.clone();
1276 async move { settings_edit(state, shell, user, code).await }
1277 },
1278 )
1279 .post(
1280 move |State(state): State<AdminState>,
1281 Extension(shell): Extension<Shell>,
1282 Extension(user): Extension<AuthenticatedUser>,
1283 Extension(session): Extension<session::SessionHandle>,
1284 Form(data): Form<HashMap<String, String>>| {
1285 let code = code.clone();
1286 async move { settings_update(state, shell, user, session, code, data).await }
1287 },
1288 ),
1289 );
1290 }
1291
1292 protected
1293 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1294 .route(
1298 &format!("{admin_path}/login"),
1299 get(login_form).post(login_submit),
1300 )
1301 .route(
1302 &format!("{admin_path}/setup"),
1303 get(setup_form).post(setup_submit),
1304 )
1305 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1306 .merge(mount_public(
1310 &app_public,
1311 &state.db,
1312 &admin_path,
1313 &state.origin,
1314 &state.plugin_defined,
1315 ))
1316 .fallback(not_found_fallback)
1318 .layer(middleware::from_fn_with_state(
1321 state.clone(),
1322 enforce_origin,
1323 ))
1324 .layer(middleware::from_fn_with_state(
1327 state.clone(),
1328 capture_client,
1329 ))
1330 .layer(
1335 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1336 header::CACHE_CONTROL,
1337 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1338 ),
1339 )
1340 .layer(
1344 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1345 header::VARY,
1346 axum::http::HeaderValue::from_static("HX-Request"),
1347 ),
1348 )
1349 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1350 handle_panic,
1351 ))
1352 .with_state(state)
1353}
1354
1355async fn not_found_fallback() -> AdminError {
1357 AdminError::NotFound
1358}
1359
1360fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1362 let cause = err
1363 .downcast_ref::<&str>()
1364 .map(|s| s.to_string())
1365 .or_else(|| err.downcast_ref::<String>().cloned())
1366 .unwrap_or_else(|| "unknown panic".to_string());
1367 tracing::error!(panic = cause, "admin handler panicked");
1368 error::masked_500()
1369}
1370
1371pub fn normalize_path(path: &str) -> String {
1376 let trimmed = path.trim().trim_matches('/');
1377 if trimmed.is_empty() {
1378 "/admin".to_string()
1379 } else {
1380 format!("/{trimmed}")
1381 }
1382}
1383
1384fn mount_public(
1388 regs: &[routes::PublicRouteReg],
1389 db: &Db,
1390 admin_path: &str,
1391 origin: &str,
1392 plugin_defined: &Arc<laterite_core::Registry>,
1393) -> Router<AdminState> {
1394 let mut router = Router::new();
1395 for reg in regs {
1396 let ctx = routes::RouteCtx::new(
1397 db.clone(),
1398 ®.path,
1399 admin_path,
1400 origin,
1401 plugin_defined.clone(),
1402 );
1403 router = router.nest_service(
1408 ®.path,
1409 reg.route.mount(&ctx).fallback(not_found_fallback),
1410 );
1411 }
1412 router
1413}
1414
1415pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1418 let old = resource.base_path.clone();
1419 resource.base_path = base.to_string();
1420 if let Some(edit_base) = &mut resource.list.edit_base {
1421 if edit_base == &old {
1422 *edit_base = base.to_string();
1423 }
1424 }
1425 if let Some(form) = &mut resource.form {
1426 if form.base_path == old {
1427 form.base_path = base.to_string();
1428 }
1429 }
1430}
1431
1432fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1433 resource.base_path = format!("{admin_path}{}", resource.base_path);
1434 if let Some(edit_base) = &mut resource.list.edit_base {
1435 *edit_base = format!("{admin_path}{edit_base}");
1436 }
1437 if let Some(form) = &mut resource.form {
1438 form.base_path = format!("{admin_path}{}", form.base_path);
1439 }
1440}
1441
1442pub(crate) struct AdminAsset {
1444 pub mime: &'static str,
1445 pub bytes: std::borrow::Cow<'static, [u8]>,
1449}
1450
1451pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1454
1455pub(crate) type AssetUrls = HashMap<&'static str, String>;
1459
1460pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
1462 registry
1463 .iter()
1464 .map(|(&key, asset)| {
1465 (
1466 key,
1467 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
1468 )
1469 })
1470 .collect()
1471}
1472
1473pub(crate) fn page_assets(
1480 keys: &[&str],
1481 base: &str,
1482 registry: &AssetRegistry,
1483 urls: &AssetUrls,
1484) -> Vec<PageAsset> {
1485 let mut seen = std::collections::HashSet::new();
1486 let mut out = Vec::new();
1487 for &key in keys {
1488 if !seen.insert(key) {
1489 continue;
1490 }
1491 match (registry.get(key), urls.get(key)) {
1492 (Some(asset), Some(path)) => out.push(PageAsset {
1493 url: format!("{base}/assets/{path}"),
1494 css: asset.mime.contains("css"),
1495 }),
1496 _ => debug_assert!(false, "asset key `{key}` is not registered"),
1497 }
1498 }
1499 out
1500}
1501
1502pub(crate) fn builtin_assets() -> AssetRegistry {
1511 let font = |bytes: &'static [u8]| AdminAsset {
1512 mime: "font/woff2",
1513 bytes: std::borrow::Cow::Borrowed(bytes),
1514 };
1515 HashMap::from([
1516 (
1517 "laterite.css",
1518 AdminAsset {
1519 mime: "text/css; charset=utf-8",
1520 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
1521 },
1522 ),
1523 (
1524 "laterite.js",
1525 AdminAsset {
1526 mime: "text/javascript; charset=utf-8",
1527 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
1528 },
1529 ),
1530 (
1531 "vendor/htmx.min.js",
1532 AdminAsset {
1533 mime: "text/javascript; charset=utf-8",
1534 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
1535 },
1536 ),
1537 (
1538 "fields/ref-picker.js",
1539 AdminAsset {
1540 mime: "text/javascript; charset=utf-8",
1541 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
1542 },
1543 ),
1544 (
1545 "fields/ref-picker.css",
1546 AdminAsset {
1547 mime: "text/css; charset=utf-8",
1548 bytes: std::borrow::Cow::Borrowed(include_bytes!(
1549 "../assets/fields/ref-picker.css"
1550 )),
1551 },
1552 ),
1553 (
1554 "mark.svg",
1555 AdminAsset {
1556 mime: "image/svg+xml",
1557 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
1558 },
1559 ),
1560 (
1561 "mark.png",
1562 AdminAsset {
1563 mime: "image/png",
1564 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
1565 },
1566 ),
1567 (
1568 "fonts/space-grotesk-500.woff2",
1569 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1570 ),
1571 (
1572 "fonts/space-grotesk-600.woff2",
1573 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1574 ),
1575 (
1576 "fonts/space-grotesk-700.woff2",
1577 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1578 ),
1579 (
1580 "fonts/ibm-plex-sans-400.woff2",
1581 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1582 ),
1583 (
1584 "fonts/ibm-plex-sans-600.woff2",
1585 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1586 ),
1587 (
1588 "fonts/ibm-plex-mono-400.woff2",
1589 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1590 ),
1591 (
1592 "fonts/ibm-plex-mono-600.woff2",
1593 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1594 ),
1595 ])
1596}
1597
1598async fn serve_asset(
1600 State(state): State<AdminState>,
1601 headers: HeaderMap,
1602 Path(path): Path<String>,
1603) -> Response {
1604 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
1608 let Some(asset) = state.assets.get(key.as_str()) else {
1609 return not_found();
1610 };
1611 let policy = if fingerprinted {
1612 http_cache::IMMUTABLE
1613 } else {
1614 http_cache::REVALIDATE
1615 };
1616 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
1617}
1618
1619fn mount_resource(
1624 resource: &Resource,
1625 field_types: &field::FieldRegistry,
1626 persisters: &persist::PersisterRegistry,
1627 listeners: &[laterite_core::ModelListenerReg],
1628) -> Router<AdminState> {
1629 if resource.form.is_some() && resource.permission.is_none() {
1634 panic!(
1635 "admin resource `{}` has a create/edit form but no permission; a write \
1636 resource must declare a permission",
1637 resource.base_path
1638 );
1639 }
1640 let base = resource.base_path.clone();
1641 let list_path = base.clone();
1642 let list_cfg = resource.list.clone();
1643 let mut router = Router::new().route(
1644 &base,
1645 get(
1646 move |State(state): State<AdminState>,
1647 Extension(shell): Extension<Shell>,
1648 Query(params): Query<list::ListParams>,
1649 Query(raw): Query<std::collections::HashMap<String, String>>,
1650 Extension(user): Extension<AuthenticatedUser>,
1651 headers: axum::http::HeaderMap| {
1652 let cfg = list_cfg.clone();
1653 let path = list_path.clone();
1654 async move {
1655 list::handle(&state, &cfg, &path, params, &raw, &user, shell, &headers).await
1656 }
1657 },
1658 ),
1659 );
1660
1661 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
1664 if resource.list.exportable {
1665 router = router.route(
1666 &format!("{base}/export"),
1667 get(
1668 move |state: State<AdminState>,
1669 Query(raw): Query<std::collections::HashMap<String, String>>,
1670 Extension(user): Extension<AuthenticatedUser>,
1671 Extension(shell): Extension<Shell>,
1672 Extension(session): Extension<session::SessionHandle>| {
1673 let cfg = export_cfg.clone();
1674 let path = export_path.clone();
1675 async move {
1676 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
1677 }
1678 },
1679 ),
1680 );
1681 }
1682
1683 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
1684 router = router.route(
1685 &format!("{base}/columns"),
1686 post(
1687 move |State(state): State<AdminState>,
1688 Extension(user): Extension<AuthenticatedUser>,
1689 Extension(session): Extension<session::SessionHandle>,
1690 headers: axum::http::HeaderMap,
1691 Form(pairs): Form<Vec<(String, String)>>| {
1692 let cfg = columns_cfg.clone();
1693 let path = columns_path.clone();
1694 async move {
1695 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
1696 }
1697 },
1698 ),
1699 );
1700
1701 if resource.list.deletable {
1702 let persister: Arc<dyn persist::Persister> =
1706 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
1707 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
1708 panic!(
1709 "admin resource `{}`: unregistered persister `{key}`",
1710 resource.base_path
1711 )
1712 }),
1713 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
1714 };
1715 let ctx = Arc::new(bulk::BulkContext {
1716 base_path: resource.base_path.clone(),
1717 entity: resource.list.entity.clone(),
1718 persister,
1719 listeners: listeners
1720 .iter()
1721 .filter(|reg| reg.matches(&resource.list.entity))
1722 .map(|reg| reg.listener.clone())
1723 .collect(),
1724 });
1725 router =
1726 router.route(
1727 &format!("{base}/delete"),
1728 post(
1729 move |state: State<AdminState>,
1730 user: Extension<AuthenticatedUser>,
1731 session: Extension<session::SessionHandle>,
1732 headers: axum::http::HeaderMap,
1733 form: Form<Vec<(String, String)>>| {
1734 let ctx = ctx.clone();
1735 async move {
1736 bulk::delete_handler(state, user, session, ctx, headers, form).await
1737 }
1738 },
1739 ),
1740 );
1741 }
1742
1743 if let Some(form_cfg) = resource.form.clone() {
1744 let prepared = Arc::new(
1747 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
1748 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1749 );
1750 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1751 router = router.route(
1752 &format!("{base}/new"),
1753 get(
1754 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1755 let pf = new_pf.clone();
1756 async move { form::new_form(&state, &pf, shell) }
1757 },
1758 )
1759 .post(
1760 move |State(state): State<AdminState>,
1761 Extension(shell): Extension<Shell>,
1762 Extension(user): Extension<AuthenticatedUser>,
1763 Extension(session): Extension<session::SessionHandle>,
1764 headers: axum::http::HeaderMap,
1765 Form(data): Form<HashMap<String, String>>| {
1766 let pf = create_pf.clone();
1767 async move {
1768 form::create(&state, &pf, data, shell, &user, &session, &headers).await
1769 }
1770 },
1771 ),
1772 );
1773
1774 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1775 router = router.route(
1776 &format!("{base}/{{id}}/edit"),
1777 get(
1778 move |State(state): State<AdminState>,
1779 Extension(shell): Extension<Shell>,
1780 Path(id): Path<String>| {
1781 let pf = edit_pf.clone();
1782 async move { form::edit_form(&state, &pf, id, shell).await }
1783 },
1784 )
1785 .post(
1786 move |State(state): State<AdminState>,
1787 Extension(shell): Extension<Shell>,
1788 Extension(user): Extension<AuthenticatedUser>,
1789 Extension(session): Extension<session::SessionHandle>,
1790 Path(id): Path<String>,
1791 headers: axum::http::HeaderMap,
1792 Form(data): Form<HashMap<String, String>>| {
1793 let pf = update_pf.clone();
1794 async move {
1795 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
1796 }
1797 },
1798 ),
1799 );
1800 }
1801
1802 if let Some(permission) = &resource.permission {
1804 router = guard_with_permission(router, permission);
1805 }
1806 router
1807}
1808
1809fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
1814 let needed: Arc<str> = Arc::from(permission);
1815 router.route_layer(middleware::from_fn(
1816 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
1817 let needed = needed.clone();
1818 async move {
1819 if user.allows(&needed) {
1820 next.run(req).await
1821 } else {
1822 forbidden()
1823 }
1824 }
1825 },
1826 ))
1827}
1828
1829async fn require_auth(
1835 State(state): State<AdminState>,
1836 jar: CookieJar,
1837 request: Request,
1838 next: Next,
1839) -> Response {
1840 let login = format!("{}/login", state.admin_path);
1841 let client_ctx = request
1842 .extensions()
1843 .get::<RequestContext>()
1844 .cloned()
1845 .unwrap_or_default();
1846 let live = match jar.get(SESSION_COOKIE) {
1850 Some(cookie) => {
1851 let token = cookie.value().to_string();
1852 state
1853 .auth
1854 .resolve_session(&token)
1855 .await
1856 .ok()
1857 .map(|resolved| (token, resolved))
1858 }
1859 None => None,
1860 };
1861 let (token, resolved, recalled) = match live {
1862 Some((token, resolved)) => (token, resolved, None),
1863 None => match recall(&state, &jar, &client_ctx).await {
1864 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
1865 None => {
1868 return (
1869 jar.remove(remember_removal(&state.admin_path)),
1870 Redirect::to(&login),
1871 )
1872 .into_response()
1873 }
1874 },
1875 };
1876 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
1877
1878 let mut request = request;
1883 let safe = session::is_safe_method(request.method());
1884 let mut deferred: Option<session::CsrfPending> = None;
1886 if !safe {
1887 let (parts, body) = request.into_parts();
1888 let (body, submitted) = if session::is_multipart(&parts.headers) {
1895 let token = session::header_token(&parts.headers)
1896 .or_else(|| session::query_token(parts.uri.query()));
1897 if token.is_none() {
1902 deferred = Some(session::CsrfPending::default());
1903 }
1904 (body, token)
1905 } else {
1906 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
1907 .await
1908 .unwrap_or_default();
1909 let token = session::submitted_token(&parts.headers, &bytes);
1910 (Body::from(bytes), token)
1911 };
1912 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
1913 {
1914 tracing::warn!(
1915 user_id = resolved.identity.user.id,
1916 "admin CSRF check failed"
1917 );
1918 return error::csrf_rejected();
1919 }
1920 request = Request::from_parts(parts, body);
1921 }
1922
1923 let flash = if safe {
1926 handle.take_flash()
1927 } else {
1928 Vec::new()
1929 };
1930 let user = resolved.identity;
1931 let acting_user_id = user.user.id;
1933 let path = request.uri().path().to_string();
1934 let accept_language = request
1938 .headers()
1939 .get("accept-language")
1940 .and_then(|v| v.to_str().ok());
1941 let serveable = state.catalogs.locales();
1942 let chain = resolve_locale_chain(
1943 user.user.locale.as_deref(),
1944 accept_language,
1945 &state.default_locale,
1946 &serveable,
1947 );
1948 let i18n = Translator::with_chain(chain, state.catalogs.clone());
1949 let translator = i18n.clone();
1952 let (sidebar, active_nav) = resolve_nav_context(
1953 &state.nav,
1954 &state.settings,
1955 &state.admin_path,
1956 &user.permissions,
1957 &path,
1958 &i18n,
1959 icons::Icons::new(&state.icons, &state.sprite_url),
1960 );
1961 let brand = state.brand().await;
1962 let shell = Shell::new(
1963 &state.admin_path,
1964 brand,
1965 &state.nav,
1966 &user,
1967 state.timezone,
1968 sidebar,
1969 active_nav.as_deref(),
1970 handle.csrf_token(),
1971 flash,
1972 i18n,
1973 state.asset_urls.clone(),
1974 state.origin.to_string(),
1975 state.icons.clone(),
1976 state.sprite_url.clone(),
1977 );
1978 request.extensions_mut().insert(user);
1979 request.extensions_mut().insert(shell);
1980 request.extensions_mut().insert(handle.clone());
1981 if let Some(pending) = &deferred {
1983 request.extensions_mut().insert(pending.clone());
1984 }
1985 let mut response = next.run(request).await;
1986
1987 if let Some(pending) = &deferred {
1992 if !pending.was_satisfied() {
1993 tracing::error!(
1994 user_id = acting_user_id,
1995 "a multipart route returned without verifying its request token"
1996 );
1997 return error::csrf_rejected();
1998 }
1999 }
2000
2001 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2005 response = error::localized_error(meta.kind, &translator);
2006 }
2007
2008 if let Some(blob) = handle.dirty_blob() {
2011 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2012 tracing::error!(error = %e, "persisting admin session failed");
2013 }
2014 }
2015 if let Some(credential) = recalled {
2018 let jar = jar
2019 .add(session_cookie(
2020 token,
2021 &state.admin_path,
2022 state.secure_cookie,
2023 ))
2024 .add(remember_cookie(
2025 credential,
2026 &state.admin_path,
2027 state.secure_cookie,
2028 ));
2029 return (jar, response).into_response();
2030 }
2031 response
2032}
2033
2034async fn recall(
2038 state: &AdminState,
2039 jar: &CookieJar,
2040 ctx: &RequestContext,
2041) -> Option<(
2042 String,
2043 laterite_auth::ResolvedSession,
2044 laterite_auth::RememberCredential,
2045)> {
2046 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
2047 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
2048 let token = recalled.session.token.clone();
2049 let resolved = state.auth.resolve_session(&token).await.ok()?;
2050 Some((token, resolved, recalled.remember))
2051}
2052
2053const MAX_FORM_BYTES: usize = 1024 * 1024;
2056
2057async fn capture_client(
2065 State(state): State<AdminState>,
2066 mut request: Request,
2067 next: Next,
2068) -> Response {
2069 let peer = request
2070 .extensions()
2071 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
2072 .map(|info| info.0);
2073 let headers = request.headers();
2074 let ctx = RequestContext {
2075 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
2076 user_agent: clientip::user_agent(headers),
2077 };
2078 request.extensions_mut().insert(ctx);
2079 next.run(request).await
2080}
2081
2082async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
2083 if !session::is_safe_method(request.method())
2084 && !session::origin_ok(request.headers(), &state.origin)
2085 {
2086 tracing::warn!(
2087 path = request.uri().path(),
2088 "admin CSRF origin check failed"
2089 );
2090 return error::csrf_rejected();
2091 }
2092 next.run(request).await
2093}
2094
2095async fn login_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2096 match state.auth.has_any_operator().await {
2098 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
2099 Ok(true) => render(LoginTemplate {
2100 base: state.admin_path.to_string(),
2101 asset_urls: state.asset_urls.clone(),
2102 brand: state.brand().await,
2103 error: None,
2104 i18n: pre_auth_translator(&state, &headers),
2105 }),
2106 Err(_) => render_error(),
2107 }
2108}
2109
2110#[derive(Deserialize)]
2111struct LoginForm {
2112 username: String,
2113 password: String,
2114 #[serde(default)]
2116 remember: Option<String>,
2117}
2118
2119async fn login_submit(
2120 State(state): State<AdminState>,
2121 jar: CookieJar,
2122 headers: HeaderMap,
2123 Extension(ctx): Extension<RequestContext>,
2124 Form(form): Form<LoginForm>,
2125) -> Response {
2126 match state
2127 .auth
2128 .authenticate(&form.username, &form.password, &ctx)
2129 .await
2130 {
2131 Ok(session) => {
2132 let wants_remember = form.remember.is_some();
2133 let user_id = session.user_id;
2134 let mut jar = jar.add(session_cookie(
2135 session.token,
2136 &state.admin_path,
2137 state.secure_cookie,
2138 ));
2139 if wants_remember {
2140 match state.auth.issue_remember(user_id).await {
2141 Ok(credential) => {
2142 jar = jar.add(remember_cookie(
2143 credential,
2144 &state.admin_path,
2145 state.secure_cookie,
2146 ));
2147 }
2148 Err(e) => {
2150 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
2151 }
2152 }
2153 }
2154 (jar, Redirect::to(&state.admin_path)).into_response()
2155 }
2156 Err(_) => {
2157 let i18n = pre_auth_translator(&state, &headers);
2158 render(LoginTemplate {
2159 base: state.admin_path.to_string(),
2160 asset_urls: state.asset_urls.clone(),
2161 brand: state.brand().await,
2162 error: Some(i18n.t(&t!("Invalid username or password."))),
2163 i18n,
2164 })
2165 }
2166 }
2167}
2168
2169fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
2178 Cookie::build((SESSION_COOKIE, token))
2179 .path(admin_path.to_string())
2180 .http_only(true)
2181 .secure(secure)
2182 .same_site(SameSite::Lax)
2183 .build()
2184}
2185
2186fn remember_cookie(
2189 credential: laterite_auth::RememberCredential,
2190 admin_path: &str,
2191 secure: bool,
2192) -> Cookie<'static> {
2193 let seconds = (credential.expires_at - chrono::Utc::now())
2194 .num_seconds()
2195 .max(0);
2196 Cookie::build((REMEMBER_COOKIE, credential.cookie))
2197 .path(admin_path.to_string())
2198 .http_only(true)
2199 .secure(secure)
2200 .same_site(SameSite::Lax)
2201 .max_age(cookie::time::Duration::seconds(seconds))
2202 .build()
2203}
2204
2205fn remember_removal(admin_path: &str) -> Cookie<'static> {
2207 Cookie::build((REMEMBER_COOKIE, ""))
2208 .path(admin_path.to_string())
2209 .build()
2210}
2211
2212#[derive(Deserialize)]
2213struct SetupForm {
2214 username: String,
2215 first_name: String,
2216 last_name: String,
2217 email: String,
2218 password: String,
2219 timezone: String,
2220}
2221
2222async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2225 match state.auth.has_any_operator().await {
2226 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2227 Ok(false) => render(setup_view(
2228 &state.admin_path,
2229 state.brand().await,
2230 state.timezone,
2231 None,
2232 pre_auth_translator(&state, &headers),
2233 state.asset_urls.clone(),
2234 )),
2235 Err(_) => render_error(),
2236 }
2237}
2238
2239async fn setup_submit(
2240 State(state): State<AdminState>,
2241 jar: CookieJar,
2242 headers: HeaderMap,
2243 Form(form): Form<SetupForm>,
2244) -> Response {
2245 match state.auth.has_any_operator().await {
2247 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2248 Ok(false) => {}
2249 Err(_) => return render_error(),
2250 }
2251
2252 let username = form.username.trim();
2253 let email = form.email.trim();
2254 let first_name = form.first_name.trim();
2255 let last_name = form.last_name.trim();
2256 let tz = form.timezone.trim();
2257 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
2258 {
2259 return render(setup_view(
2260 &state.admin_path,
2261 state.brand().await,
2262 state.timezone,
2263 Some(t!(
2264 "Username, first name, email, and password are all required."
2265 )),
2266 pre_auth_translator(&state, &headers),
2267 state.asset_urls.clone(),
2268 ));
2269 }
2270 if tz.parse::<Tz>().is_err() {
2272 return render(setup_view(
2273 &state.admin_path,
2274 state.brand().await,
2275 state.timezone,
2276 Some(t!("That is not a recognised timezone.")),
2277 pre_auth_translator(&state, &headers),
2278 state.asset_urls.clone(),
2279 ));
2280 }
2281
2282 let new = NewOperator {
2283 username,
2284 email,
2285 first_name,
2286 last_name: (!last_name.is_empty()).then_some(last_name),
2287 password: &form.password,
2288 timezone: Some(tz),
2289 };
2290 if state.auth.create_superuser(new).await.is_err() {
2291 return render(setup_view(
2292 &state.admin_path,
2293 state.brand().await,
2294 state.timezone,
2295 Some(t!(
2296 "Could not create the account. The username or email may already be taken."
2297 )),
2298 pre_auth_translator(&state, &headers),
2299 state.asset_urls.clone(),
2300 ));
2301 }
2302
2303 match state
2305 .auth
2306 .authenticate(username, &form.password, &RequestContext::default())
2307 .await
2308 {
2309 Ok(session) => {
2310 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
2311 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
2312 }
2313 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2314 }
2315}
2316
2317fn setup_view(
2320 admin_path: &str,
2321 brand: String,
2322 default_tz: Tz,
2323 error: Option<Text>,
2324 i18n: Translator,
2325 asset_urls: Arc<AssetUrls>,
2326) -> SetupTemplate {
2327 let default_name = default_tz.name();
2328 let zones = TZ_VARIANTS
2329 .iter()
2330 .filter(|tz| zone_offered(tz.name()))
2331 .map(|tz| TzOption {
2332 name: tz.name().to_string(),
2333 selected: tz.name() == default_name,
2334 })
2335 .collect();
2336 SetupTemplate {
2337 base: admin_path.to_string(),
2338 asset_urls,
2339 brand,
2340 zones,
2341 error: error.map(|e| i18n.t(&e)),
2342 i18n,
2343 }
2344}
2345
2346async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
2347 if let Some(cookie) = jar.get(SESSION_COOKIE) {
2348 let _ = state.auth.logout(cookie.value()).await;
2349 }
2350 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2353 let _ = state.auth.revoke_remember(cookie.value()).await;
2354 }
2355 let removal = Cookie::build((SESSION_COOKIE, ""))
2356 .path(state.admin_path.to_string())
2357 .build();
2358 (
2359 jar.remove(removal)
2360 .remove(remember_removal(&state.admin_path)),
2361 Redirect::to(&format!("{}/login", state.admin_path)),
2362 )
2363 .into_response()
2364}
2365
2366async fn dashboard(
2367 Extension(shell): Extension<Shell>,
2368 Extension(user): Extension<AuthenticatedUser>,
2369) -> Response {
2370 render(DashboardTemplate {
2371 username: user.user.username,
2372 shell,
2373 })
2374}
2375
2376fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
2379 match &item.permission {
2380 None => true,
2381 Some(p) => perms.allows(p),
2382 }
2383}
2384
2385fn visible_settings(
2389 items: &[settings::SettingsItem],
2390 perms: &PermissionSet,
2391) -> Vec<settings::SettingsItem> {
2392 items
2393 .iter()
2394 .filter(|item| operator_can_see(item, perms))
2395 .cloned()
2396 .collect()
2397}
2398
2399async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
2400 settings::index(shell)
2401}
2402
2403async fn settings_edit(
2404 state: AdminState,
2405 shell: Shell,
2406 user: AuthenticatedUser,
2407 code: String,
2408) -> Response {
2409 let items = visible_settings(&state.settings, &user.permissions);
2412 match items
2413 .iter()
2414 .find(|item| item.code == code && item.link.is_none())
2415 {
2416 Some(item) => settings::edit_form(&state, item, shell).await,
2417 None => not_found(),
2418 }
2419}
2420
2421async fn settings_update(
2422 state: AdminState,
2423 shell: Shell,
2424 user: AuthenticatedUser,
2425 session: session::SessionHandle,
2426 code: String,
2427 data: HashMap<String, String>,
2428) -> Response {
2429 let items = visible_settings(&state.settings, &user.permissions);
2430 match items
2431 .iter()
2432 .find(|item| item.code == code && item.link.is_none())
2433 {
2434 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
2435 None => not_found(),
2436 }
2437}
2438
2439async fn preferences_form(
2441 State(state): State<AdminState>,
2442 Extension(shell): Extension<Shell>,
2443 Extension(user): Extension<AuthenticatedUser>,
2444 jar: CookieJar,
2445) -> Response {
2446 let sessions = session_rows(&state, &user, &shell, &jar).await;
2447 render(preferences_view(
2448 &shell,
2449 &user,
2450 state.timezone,
2451 &state.default_locale,
2452 &offered_locales(&state.catalogs),
2453 sessions,
2454 None,
2455 ))
2456}
2457
2458#[derive(Deserialize)]
2459struct RevokeSessionForm {
2460 id: String,
2461}
2462
2463async fn session_revoke(
2465 State(state): State<AdminState>,
2466 Extension(user): Extension<AuthenticatedUser>,
2467 Extension(session): Extension<session::SessionHandle>,
2468 Form(form): Form<RevokeSessionForm>,
2469) -> Response {
2470 match state.auth.revoke_session(user.user.id, &form.id).await {
2471 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
2472 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
2473 Err(e) => {
2474 tracing::error!(error = %e, "revoking a session failed");
2475 session.push_flash(
2476 FlashLevel::Error,
2477 t!("That session could not be signed out."),
2478 );
2479 }
2480 }
2481 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2482}
2483
2484async fn session_revoke_others(
2486 State(state): State<AdminState>,
2487 Extension(user): Extension<AuthenticatedUser>,
2488 Extension(session): Extension<session::SessionHandle>,
2489 jar: CookieJar,
2490) -> Response {
2491 let token = jar
2492 .get(SESSION_COOKIE)
2493 .map(|c| c.value().to_string())
2494 .unwrap_or_default();
2495 match state.auth.sign_out_everywhere(user.user.id, &token).await {
2496 Ok(_) => session.push_flash(
2497 FlashLevel::Success,
2498 t!("Every other session was signed out."),
2499 ),
2500 Err(e) => {
2501 tracing::error!(error = %e, "signing out other sessions failed");
2502 session.push_flash(
2503 FlashLevel::Error,
2504 t!("Those sessions could not be signed out."),
2505 );
2506 }
2507 }
2508 (
2511 jar.remove(remember_removal(&state.admin_path)),
2512 Redirect::to(&format!("{}/preferences", state.admin_path)),
2513 )
2514 .into_response()
2515}
2516
2517#[derive(Deserialize)]
2518struct PreferencesForm {
2519 timezone: String,
2520 #[serde(default)]
2522 locale: String,
2523}
2524
2525async fn preferences_update(
2526 State(state): State<AdminState>,
2527 Extension(shell): Extension<Shell>,
2528 Extension(user): Extension<AuthenticatedUser>,
2529 Extension(session): Extension<session::SessionHandle>,
2530 jar: CookieJar,
2531 Form(form): Form<PreferencesForm>,
2532) -> Response {
2533 let offered = offered_locales(&state.catalogs);
2534 let tz = form.timezone.trim();
2536 let tz_stored = if tz.is_empty() {
2537 None
2538 } else if tz.parse::<Tz>().is_ok() {
2539 Some(tz)
2540 } else {
2541 return render(preferences_view(
2542 &shell,
2543 &user,
2544 state.timezone,
2545 &state.default_locale,
2546 &offered,
2547 session_rows(&state, &user, &shell, &jar).await,
2548 Some(t!("That is not a recognised timezone.")),
2549 ));
2550 };
2551 let loc = form.locale.trim();
2552 let loc_stored = if loc.is_empty() {
2553 None
2554 } else if offered.iter().any(|l| l == loc) {
2555 Some(loc)
2556 } else {
2557 return render(preferences_view(
2558 &shell,
2559 &user,
2560 state.timezone,
2561 &state.default_locale,
2562 &offered,
2563 session_rows(&state, &user, &shell, &jar).await,
2564 Some(t!("That is not a supported language.")),
2565 ));
2566 };
2567 if state
2568 .auth
2569 .set_user_timezone(user.user.id, tz_stored)
2570 .await
2571 .is_err()
2572 {
2573 return render_error();
2574 }
2575 match state.auth.set_user_locale(user.user.id, loc_stored).await {
2576 Ok(()) => {
2577 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
2578 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2579 }
2580 Err(_) => render_error(),
2581 }
2582}
2583
2584fn preferences_view(
2588 shell: &Shell,
2589 user: &AuthenticatedUser,
2590 default_tz: Tz,
2591 default_locale: &str,
2592 offered: &[String],
2593 sessions: Vec<SessionRow>,
2594 error: Option<Text>,
2595) -> PreferencesTemplate {
2596 let current = user.user.timezone.as_deref();
2597 let zones = TZ_VARIANTS
2598 .iter()
2599 .filter(|tz| zone_offered(tz.name()))
2600 .map(|tz| TzOption {
2601 name: tz.name().to_string(),
2602 selected: current == Some(tz.name()),
2603 })
2604 .collect();
2605 let current_locale = user.user.locale.as_deref();
2606 let locales = offered
2607 .iter()
2608 .map(|code| LocaleOption {
2609 name: locale_name(code),
2610 selected: current_locale == Some(code.as_str()),
2611 code: code.clone(),
2612 })
2613 .collect();
2614 PreferencesTemplate {
2615 shell: shell.clone(),
2616 zones,
2617 effective_tz: shell.tz.name().to_string(),
2618 default_tz: default_tz.name().to_string(),
2619 inherits: current.is_none(),
2620 locales,
2621 default_locale: locale_name(default_locale),
2622 inherits_locale: current_locale.is_none(),
2623 sessions,
2624 error: error.map(|e| shell.tt(&e)),
2625 }
2626}
2627
2628async fn session_rows(
2631 state: &AdminState,
2632 user: &AuthenticatedUser,
2633 shell: &Shell,
2634 jar: &CookieJar,
2635) -> Vec<SessionRow> {
2636 let token = jar
2637 .get(SESSION_COOKIE)
2638 .map(|c| c.value().to_string())
2639 .unwrap_or_default();
2640 let locale = list::date_locale(shell.locale());
2641 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
2642 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
2643 };
2644 match state.auth.list_sessions(user.user.id, &token).await {
2645 Ok(sessions) => sessions
2646 .into_iter()
2647 .map(|s| SessionRow {
2648 id: s.id,
2649 started: stamp(s.created_at),
2650 last_seen: stamp(s.last_seen_at),
2651 expires: stamp(s.expires_at),
2652 device: clientip::describe(s.user_agent.as_deref())
2653 .or(s.user_agent)
2654 .unwrap_or_else(|| "-".to_string()),
2655 ip_address: s.ip_address.unwrap_or_default(),
2656 current: s.current,
2657 })
2658 .collect(),
2659 Err(e) => {
2660 tracing::error!(error = %e, "listing sessions failed");
2661 Vec::new()
2662 }
2663 }
2664}
2665
2666fn builtin_resources() -> Vec<Resource> {
2668 vec![
2669 Resource {
2670 base_path: "/users".to_string(),
2671 nav_label: "Backend Users".into(),
2672 list: backend_users_list_config(),
2673 form: None,
2674 permission: Some("backend.manage_users".to_string()),
2675 },
2676 Resource {
2677 base_path: "/roles".to_string(),
2678 nav_label: "Roles".into(),
2679 list: roles_list_config(),
2680 form: None,
2683 permission: Some("backend.manage_roles".to_string()),
2684 },
2685 Resource {
2686 base_path: "/audit-log".to_string(),
2687 nav_label: "Audit Log".into(),
2688 list: audit_log_list_config(),
2689 form: None,
2691 permission: Some("backend.view_audit_log".to_string()),
2692 },
2693 ]
2694}
2695
2696fn builtin_settings() -> Vec<settings::SettingsItem> {
2700 vec![
2701 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
2702 .description("Manage backend administrator accounts.")
2703 .category("Users")
2704 .order(10)
2705 .icon("users")
2706 .permission("backend.manage_users")
2707 .link("/users"),
2708 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
2709 .description("Manage roles and their permissions.")
2710 .category("Users")
2711 .order(20)
2712 .icon("shield")
2713 .permission("backend.manage_roles")
2714 .link("/roles"),
2715 settings::brand::settings_item(),
2716 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
2717 .description("Enable or disable installed plugins.")
2718 .category("System")
2719 .order(10)
2720 .icon("plug")
2721 .permission(plugins::MANAGE_PERMISSION)
2722 .link("/plugins"),
2723 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
2724 .description("Review the record of administrative changes.")
2725 .category("System")
2726 .order(20)
2727 .icon("history")
2728 .permission("backend.view_audit_log")
2729 .link("/audit-log"),
2730 ]
2731}
2732
2733fn backend_users_list_config() -> list::ListConfig {
2734 list::ListConfig {
2735 entity: "backend_users".to_string(),
2736 title: "Backend Users".into(),
2737 columns: vec![
2738 list::ListColumn::new("username", "Username"),
2739 list::ListColumn::new("email", "Email"),
2740 list::ListColumn::new("first_name", "First name"),
2741 list::ListColumn::new("last_name", "Last name"),
2742 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
2743 list::ListColumn::new("is_active", "Active").yes_no(),
2744 list::ListColumn::new("created_at", "Created").datetime(),
2745 ],
2746 order_by: "created_at".to_string(),
2747 edit_base: Some("/users".to_string()),
2750 filters: vec![
2751 list::ListFilter::boolean("is_active", "Active"),
2752 list::ListFilter::boolean("is_superuser", "Superuser"),
2753 ],
2754 ..Default::default()
2758 }
2759}
2760
2761fn roles_list_config() -> list::ListConfig {
2762 list::ListConfig {
2763 entity: "backend_roles".to_string(),
2764 title: "Roles".into(),
2765 columns: vec![
2766 list::ListColumn::new("code", "Code"),
2767 list::ListColumn::new("name", "Name"),
2768 list::ListColumn::new("created_at", "Created").datetime(),
2769 ],
2770 order_by: "created_at".to_string(),
2771 edit_base: Some("/roles".to_string()),
2772 creatable: true,
2773 deletable: true,
2774 ..Default::default()
2775 }
2776}
2777
2778fn audit_log_list_config() -> list::ListConfig {
2781 list::ListConfig {
2782 entity: "backend_audit_log".to_string(),
2783 title: "Audit Log".into(),
2784 columns: vec![
2785 list::ListColumn::new("created_at", "When").datetime(),
2786 list::ListColumn::new("actor_username", "Operator"),
2787 list::ListColumn::new("action", "Action"),
2788 list::ListColumn::new("target_type", "Target"),
2789 list::ListColumn::new("target_id", "Target ID"),
2790 ],
2791 order_by: "created_at".to_string(),
2792 per_page: 50,
2793 exportable: true,
2796 ..Default::default()
2797 }
2798}
2799
2800#[derive(Template)]
2801#[template(path = "login.html")]
2802struct LoginTemplate {
2803 base: String,
2805 asset_urls: Arc<AssetUrls>,
2807 brand: String,
2808 error: Option<String>,
2809 i18n: Translator,
2812}
2813
2814fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
2817 match urls.get(key) {
2818 Some(path) => format!("{base}/assets/{path}"),
2819 None => {
2820 debug_assert!(false, "asset key `{key}` is not registered");
2821 format!("{base}/assets/{key}")
2822 }
2823 }
2824}
2825
2826impl LoginTemplate {
2827 fn asset(&self, key: &str) -> String {
2828 preauth_asset(&self.asset_urls, &self.base, key)
2829 }
2830 fn t(&self, source: &str) -> String {
2831 self.i18n.t(&Text::dynamic(source))
2832 }
2833 fn locale(&self) -> &str {
2834 self.i18n.locale()
2835 }
2836}
2837
2838#[derive(Template)]
2839#[template(path = "dashboard.html")]
2840struct DashboardTemplate {
2841 shell: Shell,
2842 username: String,
2843}
2844
2845#[derive(Template)]
2846#[template(path = "setup.html")]
2847struct SetupTemplate {
2848 base: String,
2850 asset_urls: Arc<AssetUrls>,
2852 brand: String,
2853 zones: Vec<TzOption>,
2854 error: Option<String>,
2855 i18n: Translator,
2857}
2858
2859impl SetupTemplate {
2860 fn asset(&self, key: &str) -> String {
2861 preauth_asset(&self.asset_urls, &self.base, key)
2862 }
2863 fn t(&self, source: &str) -> String {
2864 self.i18n.t(&Text::dynamic(source))
2865 }
2866 fn locale(&self) -> &str {
2867 self.i18n.locale()
2868 }
2869}
2870
2871#[derive(Template)]
2872#[template(path = "preferences.html")]
2873struct PreferencesTemplate {
2874 shell: Shell,
2875 zones: Vec<TzOption>,
2876 effective_tz: String,
2878 default_tz: String,
2880 inherits: bool,
2882 locales: Vec<LocaleOption>,
2883 default_locale: String,
2885 inherits_locale: bool,
2887 sessions: Vec<SessionRow>,
2889 error: Option<String>,
2890}
2891
2892struct SessionRow {
2894 id: String,
2895 started: String,
2896 last_seen: String,
2897 expires: String,
2898 device: String,
2901 ip_address: String,
2903 current: bool,
2904}
2905
2906struct TzOption {
2907 name: String,
2908 selected: bool,
2909}
2910
2911struct LocaleOption {
2912 code: String,
2914 name: String,
2916 selected: bool,
2917}
2918
2919#[derive(Clone)]
2920struct NavView {
2921 label: String,
2922 path: String,
2923 active: bool,
2924 icon: String,
2927}
2928
2929pub(crate) fn render<T: Template>(template: T) -> Response {
2931 match template.render() {
2932 Ok(html) => Html(html).into_response(),
2933 Err(e) => AdminError::from(e).into_response(),
2935 }
2936}
2937
2938pub(crate) fn render_error() -> Response {
2939 error::masked_500()
2940}
2941
2942pub(crate) fn not_found() -> Response {
2943 AdminError::NotFound.into_response()
2944}
2945
2946pub(crate) fn forbidden() -> Response {
2947 AdminError::Forbidden.into_response()
2948}
2949
2950#[cfg(test)]
2951mod tests {
2952 use super::*;
2953
2954 fn write_form() -> form::FormConfig {
2955 form::FormConfig {
2956 entity: "widgets".to_string(),
2957 title: "Widgets".into(),
2958 base_path: "/widgets".to_string(),
2959 id_field: "id".to_string(),
2960 fields: vec![],
2961 persist: None,
2962 timestamps: false,
2963 }
2964 }
2965
2966 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
2967 Resource {
2968 base_path: "/widgets".to_string(),
2969 nav_label: "Widgets".into(),
2970 list: backend_users_list_config(),
2971 form,
2972 permission: permission.map(str::to_string),
2973 }
2974 }
2975
2976 #[test]
2977 #[should_panic(expected = "must declare a permission")]
2978 fn write_resource_without_permission_aborts_boot() {
2979 let resource = resource_with(Some(write_form()), None);
2982 let _ = mount_resource(
2983 &resource,
2984 &field::builtin_registry(),
2985 &persist::PersisterRegistry::new(),
2986 &[],
2987 );
2988 }
2989
2990 #[test]
2991 fn read_only_resource_may_omit_permission() {
2992 let resource = resource_with(None, None);
2994 let _ = mount_resource(
2995 &resource,
2996 &field::builtin_registry(),
2997 &persist::PersisterRegistry::new(),
2998 &[],
2999 );
3000 }
3001
3002 #[test]
3003 fn shell_localizes_through_the_translator_and_falls_back() {
3004 let shell = Shell::test();
3006 assert_eq!(shell.t("Save"), "Save");
3007 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
3008 }
3009
3010 #[test]
3011 fn shell_tf_interpolates_integer_arguments() {
3012 let shell = Shell::test();
3013 assert_eq!(
3014 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
3015 "Page 2 of 5"
3016 );
3017 }
3018
3019 #[test]
3020 fn display_tz_prefers_a_valid_operator_preference() {
3021 assert_eq!(
3022 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
3023 Tz::Asia__Kolkata
3024 );
3025 }
3026
3027 #[test]
3028 fn display_tz_falls_back_when_unset_or_invalid() {
3029 let default = Tz::Europe__London;
3030 assert_eq!(resolve_display_tz(None, default), default);
3032 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
3034 assert_eq!(resolve_display_tz(Some(""), default), default);
3035 }
3036
3037 #[test]
3038 fn accept_language_orders_by_weight_and_drops_wildcard() {
3039 assert_eq!(
3040 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
3041 vec!["en-US", "en", "kn"]
3042 );
3043 assert_eq!(
3045 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
3046 vec!["kn", "en"]
3047 );
3048 }
3049
3050 #[test]
3051 fn locale_chain_prefers_operator_then_header_then_default() {
3052 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
3054 assert_eq!(
3057 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
3058 vec!["kn", "ta", "hi", "en"]
3059 );
3060 assert_eq!(
3062 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
3063 vec!["kn", "en", "ta"]
3064 );
3065 assert_eq!(
3067 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
3068 vec!["kn", "en"]
3069 );
3070 }
3071
3072 #[test]
3073 fn locale_chain_skips_unserveable_and_defaults_to_en() {
3074 let loaded = ["hi".to_string()];
3076 assert_eq!(
3079 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
3080 vec!["en"]
3081 );
3082 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
3084 assert_eq!(
3086 resolve_locale_chain(Some("hi"), None, "en", &loaded),
3087 vec!["hi", "en"]
3088 );
3089 assert_eq!(
3091 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
3092 vec![laterite_core::PSEUDO_LOCALE, "en"]
3093 );
3094 }
3095
3096 #[test]
3097 fn page_assets_dedupe_preserve_order_and_classify() {
3098 let mut reg = AssetRegistry::new();
3099 reg.insert(
3100 "a.js",
3101 AdminAsset {
3102 mime: "text/javascript; charset=utf-8",
3103 bytes: std::borrow::Cow::Borrowed(b"//"),
3104 },
3105 );
3106 reg.insert(
3107 "b.css",
3108 AdminAsset {
3109 mime: "text/css; charset=utf-8",
3110 bytes: std::borrow::Cow::Borrowed(b"body{}"),
3111 },
3112 );
3113 let urls = asset_urls(®);
3116 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
3117 assert_eq!(assets.len(), 2);
3118 assert_eq!(
3119 assets[0].url,
3120 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
3121 );
3122 assert!(!assets[0].css);
3123 assert_eq!(
3124 assets[1].url,
3125 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
3126 );
3127 assert!(assets[1].css);
3128 assert_ne!(assets[0].url, assets[1].url);
3131 }
3132
3133 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
3137 laterite_core::testing::connect_test(&[]).await
3138 }
3139
3140 #[tokio::test]
3141 async fn builtin_migrations_create_the_admin_tables() {
3142 let (db, _guard) = empty_db().await;
3143 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
3144 .await
3145 .unwrap();
3146 for table in ["backend_users", "settings"] {
3150 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
3151 .fetch_optional(&db.pool)
3152 .await;
3153 assert!(
3154 probe.is_ok(),
3155 "{table} should exist after builtin_migrations"
3156 );
3157 }
3158 }
3159
3160 #[tokio::test]
3161 async fn brand_setting_overrides_config_and_blank_falls_back() {
3162 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
3163 let state = AdminState {
3164 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3165 db: db.clone(),
3166 nav: Arc::new(Vec::new()),
3167 settings: Arc::new(Vec::new()),
3168 permissions: Arc::new(builtin_permissions()),
3169 trusted_proxies: Arc::new(Vec::new()),
3170 icons: Arc::new(laterite_core::icons::IconSet::new()),
3171 sprite_url: Arc::from(""),
3172 admin_path: Arc::from("/admin"),
3173 secure_cookie: false,
3174 origin: Arc::from(""),
3175 timezone: Tz::UTC,
3176 default_locale: Arc::from("en"),
3177 catalogs: Arc::new(CatalogStore::default()),
3178 app_name: "Configured Name".to_string(),
3179 brand_cache: Arc::new(RwLock::new(None)),
3180 field_types: Arc::new(field::builtin_registry()),
3181 column_types: Arc::new(list::builtin_column_registry()),
3182 plugin_defined: Arc::new(laterite_core::Registry::new()),
3183 assets: Arc::new(builtin_assets()),
3184 asset_urls: Arc::new(asset_urls(&builtin_assets())),
3185 pickers: Arc::new(picker::PickerRegistry::new()),
3186 overrides: Arc::new(field::NoOverrides),
3187 };
3188
3189 assert_eq!(state.brand().await, "Configured Name");
3191
3192 settings::store::save(
3195 &db,
3196 &settings::BrandSetting {
3197 app_name: "Acme Corp".to_string(),
3198 },
3199 )
3200 .await
3201 .unwrap();
3202 state.invalidate_brand();
3203 assert_eq!(state.brand().await, "Acme Corp");
3204
3205 settings::store::save(
3207 &db,
3208 &settings::BrandSetting {
3209 app_name: " ".to_string(),
3210 },
3211 )
3212 .await
3213 .unwrap();
3214 state.invalidate_brand();
3215 assert_eq!(state.brand().await, "Configured Name");
3216 }
3217
3218 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
3219 settings::SettingsItem {
3220 code: code.to_string(),
3221 label: code.into(),
3222 description: String::new().into(),
3223 category: "General".into(),
3224 hint: None,
3225 order: 1,
3226 icon: None,
3227 permission: permission.map(str::to_string),
3228 link: None,
3229 fields: Vec::new(),
3230 route: None,
3231 }
3232 }
3233
3234 #[test]
3235 fn settings_visibility_respects_permissions() {
3236 let items = vec![
3237 settings_item("public", None),
3238 settings_item("gated", Some("backend.manage_users")),
3239 ];
3240
3241 let none = PermissionSet::new(false, Vec::<String>::new());
3243 let codes: Vec<String> = visible_settings(&items, &none)
3244 .into_iter()
3245 .map(|i| i.code)
3246 .collect();
3247 assert_eq!(codes, ["public"]);
3248
3249 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
3251 assert_eq!(visible_settings(&items, &granted).len(), 2);
3252
3253 let superuser = PermissionSet::new(true, Vec::<String>::new());
3255 assert_eq!(visible_settings(&items, &superuser).len(), 2);
3256 }
3257
3258 #[test]
3259 fn context_sidebar_follows_settings_links() {
3260 let items = builtin_settings();
3261 let superuser = PermissionSet::new(true, Vec::<String>::new());
3262 let tr = Translator::new("en");
3263 let sidebar = |path: &str| {
3264 resolve_nav_context(
3265 &[],
3266 &items,
3267 "/admin",
3268 &superuser,
3269 path,
3270 &tr,
3271 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
3272 )
3273 .0
3274 };
3275 let active_path = |path: &str| -> Option<String> {
3276 sidebar(path)
3277 .into_iter()
3278 .flat_map(|g| g.items)
3279 .find(|i| i.active)
3280 .map(|i| i.path)
3281 };
3282
3283 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
3285 assert_eq!(
3286 active_path("/admin/roles/42/edit").as_deref(),
3287 Some("/admin/roles")
3288 );
3289 assert!(!sidebar("/admin/settings").is_empty());
3291 assert_eq!(active_path("/admin/settings"), None);
3292 assert_eq!(
3297 active_path("/admin/settings/branding").as_deref(),
3298 Some("/admin/settings/branding")
3299 );
3300 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
3303 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
3304 assert!(sidebar("/admin").is_empty());
3306 }
3307
3308 #[test]
3309 fn active_nav_lights_the_right_tab() {
3310 let nav = vec![
3311 NavLink {
3312 label: "Dashboard".into(),
3313 path: "/admin".to_string(),
3314 icon: Some("layout-dashboard"),
3315 },
3316 NavLink {
3317 label: "Pages".into(),
3318 path: "/admin/pages".to_string(),
3319 icon: None,
3320 },
3321 NavLink {
3322 label: "Settings".into(),
3323 path: "/admin/settings".to_string(),
3324 icon: Some("settings"),
3325 },
3326 ];
3327
3328 assert_eq!(
3330 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
3331 Some("/admin")
3332 );
3333 assert_eq!(
3335 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
3336 Some("/admin/pages")
3337 );
3338 assert_eq!(
3340 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
3341 None
3342 );
3343 assert_eq!(
3346 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
3347 None
3348 );
3349 assert_eq!(
3352 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
3353 Some("/admin/settings")
3354 );
3355 assert_eq!(
3356 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
3357 Some("/admin/settings")
3358 );
3359 }
3360}
3361
3362#[cfg(test)]
3363mod session_cookie_tests {
3364 use super::*;
3365
3366 #[test]
3370 fn the_session_cookie_never_outlives_the_browser() {
3371 let cookie = session_cookie("tok".into(), "/admin", false);
3372 assert_eq!(cookie.max_age(), None);
3373 assert!(cookie.http_only().unwrap_or(false));
3374 assert_eq!(cookie.path(), Some("/admin"));
3375 }
3376
3377 #[test]
3380 fn the_remember_cookie_carries_its_own_lifetime() {
3381 let credential = laterite_auth::RememberCredential {
3382 cookie: "sel:ver".into(),
3383 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
3384 };
3385 let cookie = remember_cookie(credential, "/admin", true);
3386 let age = cookie.max_age().expect("a remember cookie must persist");
3387 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
3389 assert!(cookie.http_only().unwrap_or(false));
3390 assert!(cookie.secure().unwrap_or(false));
3391 }
3392
3393 #[test]
3395 fn the_removal_matches_the_cookie_it_clears() {
3396 let removal = remember_removal("/admin");
3397 assert_eq!(removal.name(), REMEMBER_COOKIE);
3398 assert_eq!(removal.path(), Some("/admin"));
3399 }
3400}