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
131#[derive(Clone)]
133pub(crate) struct AdminState {
134 auth: AuthService,
135 db: Db,
136 nav: Arc<Vec<NavLink>>,
137 settings: Arc<Vec<settings::SettingsItem>>,
138 permissions: Arc<Vec<Permission>>,
139 admin_path: Arc<str>,
143 secure_cookie: bool,
144 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
146 origin: Arc<str>,
148 timezone: Tz,
149 default_locale: Arc<str>,
153 catalogs: Arc<CatalogStore>,
156 app_name: String,
159 brand_cache: Arc<RwLock<Option<String>>>,
163 asset_urls: Arc<AssetUrls>,
165 field_types: Arc<field::FieldRegistry>,
168 column_types: Arc<list::ColumnRegistry>,
171 plugin_defined: Arc<laterite_core::Registry>,
174 assets: Arc<AssetRegistry>,
176 pickers: Arc<picker::PickerRegistry>,
179 overrides: Arc<dyn field::OverrideResolver>,
183}
184
185impl AdminState {
186 #[cfg(test)]
187 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
188 Self {
189 auth,
190 db,
191 nav: Arc::new(Vec::new()),
192 settings: Arc::new(Vec::new()),
193 permissions: Arc::new(builtin_permissions()),
194 trusted_proxies: Arc::new(Vec::new()),
195 admin_path: Arc::from("/admin"),
196 secure_cookie: false,
197 origin: Arc::from(""),
198 timezone: Tz::UTC,
199 default_locale: Arc::from("en"),
200 catalogs: Arc::new(CatalogStore::default()),
201 app_name: "Laterite".to_string(),
202 brand_cache: Arc::new(RwLock::new(None)),
203 field_types: Arc::new(field::builtin_registry()),
204 column_types: Arc::new(list::builtin_column_registry()),
205 plugin_defined: Arc::new(laterite_core::Registry::new()),
206 assets: Arc::new(builtin_assets()),
207 asset_urls: Arc::new(asset_urls(&builtin_assets())),
208 pickers: Arc::new(picker::PickerRegistry::new()),
209 overrides: Arc::new(field::NoOverrides),
210 }
211 }
212
213 async fn brand(&self) -> String {
217 {
218 let cached = self.brand_cache.read().unwrap().clone();
219 if let Some(name) = cached {
220 return name;
221 }
222 }
223 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
224 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
225 _ => self.app_name.clone(),
226 };
227 *self.brand_cache.write().unwrap() = Some(resolved.clone());
228 resolved
229 }
230
231 fn invalidate_brand(&self) {
233 *self.brand_cache.write().unwrap() = None;
234 }
235}
236
237#[derive(Clone)]
243#[non_exhaustive]
244pub struct AdminConfig {
245 pub secure_cookie: bool,
248 pub trusted_proxies: Vec<String>,
252 pub timezone: String,
256 pub locale: String,
260 pub app_name: String,
264 pub path: String,
268 pub origin: String,
273}
274
275impl Default for AdminConfig {
276 fn default() -> Self {
277 Self {
278 secure_cookie: false,
279 trusted_proxies: Vec::new(),
280 timezone: "UTC".to_string(),
281 locale: "en".to_string(),
282 app_name: "Laterite".to_string(),
283 path: "/admin".to_string(),
284 origin: String::new(),
285 }
286 }
287}
288
289#[derive(Clone)]
290struct NavLink {
291 label: Text,
292 path: String,
293 icon: Option<&'static str>,
296}
297
298#[derive(Clone)]
303pub(crate) struct Shell {
304 pub(crate) base: String,
308 brand: String,
312 nav: Vec<NavView>,
313 full_name: String,
314 initial: String,
315 tz: Tz,
319 i18n: Translator,
323 sidebar: Vec<settings::CategoryView>,
327 site_url: String,
331 asset_urls: Arc<AssetUrls>,
335 pub(crate) csrf_token: String,
339 pub(crate) flash: Vec<FlashLine>,
343 pub(crate) assets: Vec<PageAsset>,
347}
348
349#[derive(Clone)]
351pub(crate) struct PageAsset {
352 pub(crate) url: String,
353 pub(crate) css: bool,
354}
355
356#[derive(Clone)]
359pub(crate) struct FlashLine {
360 pub(crate) level: session::FlashLevel,
361 pub(crate) text: String,
362}
363
364impl Shell {
365 #[allow(clippy::too_many_arguments)]
366 fn new(
367 base: &str,
368 brand: String,
369 nav: &[NavLink],
370 user: &AuthenticatedUser,
371 default_tz: Tz,
372 sidebar: Vec<settings::CategoryView>,
373 active_nav: Option<&str>,
374 csrf_token: String,
375 flash: Vec<session::Flash>,
376 i18n: Translator,
377 asset_urls: Arc<AssetUrls>,
378 site_url: String,
379 ) -> Self {
380 let full_name = user.user.full_name();
381 let initial = full_name
382 .chars()
383 .next()
384 .map(|c| c.to_uppercase().to_string())
385 .unwrap_or_else(|| "?".to_string());
386 let flash = flash
389 .into_iter()
390 .map(|f| FlashLine {
391 level: f.level,
392 text: i18n.t(&f.text),
393 })
394 .collect();
395 let nav = nav
396 .iter()
397 .map(|n| NavView {
398 label: i18n.t(&n.label),
399 path: n.path.clone(),
400 active: active_nav == Some(n.path.as_str()),
401 icon: n.icon.map(|name| icons::svg(Some(name))).unwrap_or(""),
402 })
403 .collect();
404 Shell {
405 asset_urls,
406 site_url,
407 base: base.to_string(),
408 brand,
409 nav,
410 full_name,
411 initial,
412 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
413 i18n,
414 sidebar,
415 csrf_token,
416 flash,
417 assets: Vec::new(),
418 }
419 }
420
421 pub(crate) fn site_url(&self) -> &str {
423 &self.site_url
424 }
425
426 pub(crate) fn icon(&self, name: &str) -> &'static str {
428 icons::svg(Some(name))
429 }
430
431 pub(crate) fn asset(&self, key: &str) -> String {
436 match self.asset_urls.get(key) {
437 Some(path) => format!("{}/assets/{path}", self.base),
438 None => {
439 debug_assert!(false, "asset key `{key}` is not registered");
440 format!("{}/assets/{key}", self.base)
441 }
442 }
443 }
444
445 pub(crate) fn t(&self, source: &str) -> String {
448 self.i18n.t(&Text::dynamic(source))
449 }
450
451 pub(crate) fn tt(&self, text: &Text) -> String {
454 self.i18n.t(text)
455 }
456
457 pub(crate) fn i18n(&self) -> &Translator {
460 &self.i18n
461 }
462
463 pub(crate) fn locale(&self) -> &str {
465 self.i18n.locale()
466 }
467
468 pub(crate) fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
472 let mut text = Text::dynamic(source);
473 for (name, value) in args {
474 text = text.arg(*name, *value);
475 }
476 self.i18n.t(&text)
477 }
478
479 pub(crate) fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
482 let mut text = Text::dynamic(source);
483 for (name, value) in args {
484 text = text.arg(*name, *value);
485 }
486 self.i18n.t(&text)
487 }
488
489 #[cfg(test)]
490 pub(crate) fn test() -> Self {
491 Shell {
492 asset_urls: Arc::new(asset_urls(&builtin_assets())),
493 site_url: "http://localhost".to_string(),
494 base: "/admin".to_string(),
495 brand: "Laterite".to_string(),
496 nav: Vec::new(),
497 full_name: "Test Operator".to_string(),
498 initial: "T".to_string(),
499 tz: Tz::UTC,
500 i18n: Translator::new("en"),
501 sidebar: Vec::new(),
502 csrf_token: "test-csrf-token".to_string(),
503 flash: Vec::new(),
504 assets: Vec::new(),
505 }
506 }
507}
508
509fn settings_context(
516 visible: &[settings::SettingsItem],
517 admin_path: &str,
518 path: &str,
519) -> (bool, Option<String>) {
520 if path == format!("{admin_path}/settings") {
521 return (true, None);
522 }
523 let own = visible
529 .iter()
530 .filter(|i| i.link.is_none())
531 .find(|i| path == format!("{admin_path}{}", i.route()))
532 .map(|i| i.code.clone());
533 if own.is_some() {
534 return (true, own);
535 }
536 let linked = visible
537 .iter()
538 .filter_map(|i| {
539 i.link
540 .as_deref()
541 .map(|link| (format!("{admin_path}{link}"), &i.code))
542 })
543 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
544 .max_by_key(|(link, _)| link.len())
545 .map(|(_, code)| code.clone());
546 (linked.is_some(), linked)
547}
548
549fn active_nav_path(
557 nav: &[NavLink],
558 admin_path: &str,
559 in_settings_context: bool,
560 path: &str,
561) -> Option<String> {
562 if in_settings_context {
563 return Some(format!("{admin_path}/settings"));
564 }
565 nav.iter()
566 .filter(|n| {
567 path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
568 })
569 .max_by_key(|n| n.path.len())
570 .map(|n| n.path.clone())
571}
572
573fn resolve_nav_context(
578 nav: &[NavLink],
579 items: &[settings::SettingsItem],
580 admin_path: &str,
581 perms: &PermissionSet,
582 path: &str,
583 tr: &Translator,
584) -> (Vec<settings::CategoryView>, Option<String>) {
585 let visible = visible_settings(items, perms);
586 let (in_context, active) = settings_context(&visible, admin_path, path);
587 let sidebar = if in_context {
588 settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr)
589 } else {
590 Vec::new()
591 };
592 let active_nav = active_nav_path(nav, admin_path, in_context, path);
593 (sidebar, active_nav)
594}
595
596fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
600 preference
601 .and_then(|name| name.parse::<Tz>().ok())
602 .unwrap_or(default_tz)
603}
604
605fn zone_offered(name: &str) -> bool {
607 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
608}
609
610fn base_lang(tag: &str) -> String {
613 tag.split(['-', '_'])
614 .next()
615 .unwrap_or(tag)
616 .to_ascii_lowercase()
617}
618
619fn is_serveable(base: &str, serveable: &[String]) -> bool {
625 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
626}
627
628fn default_locale(configured: &str, serveable: &[String]) -> String {
631 let base = base_lang(configured);
632 if is_serveable(&base, serveable) {
633 base
634 } else {
635 "en".to_string()
636 }
637}
638
639fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
643 let mut out = vec!["en".to_string()];
644 out.extend(catalogs.locales());
645 out
646}
647
648fn locale_name(code: &str) -> String {
653 if code == "en" {
654 "English".to_string()
655 } else {
656 code.to_string()
657 }
658}
659
660fn parse_accept_language(header: &str) -> Vec<String> {
663 let mut items: Vec<(f32, String)> = header
664 .split(',')
665 .filter_map(|part| {
666 let mut bits = part.split(';');
667 let tag = bits.next()?.trim();
668 if tag.is_empty() || tag == "*" {
669 return None;
670 }
671 let q = bits
672 .find_map(|p| p.trim().strip_prefix("q="))
673 .and_then(|q| q.parse::<f32>().ok())
674 .unwrap_or(1.0);
675 Some((q, tag.to_string()))
676 })
677 .collect();
678 items.sort_by(|a, b| b.0.total_cmp(&a.0));
680 items.into_iter().map(|(_, tag)| tag).collect()
681}
682
683fn resolve_locale_chain(
689 preference: Option<&str>,
690 accept_language: Option<&str>,
691 default: &str,
692 serveable: &[String],
693) -> Vec<String> {
694 let mut chain: Vec<String> = Vec::new();
695 let consider = |tag: &str, chain: &mut Vec<String>| {
696 let base = base_lang(tag);
697 if is_serveable(&base, serveable) && !chain.contains(&base) {
698 chain.push(base);
699 }
700 };
701 if let Some(pref) = preference {
702 consider(pref, &mut chain);
703 }
704 if let Some(header) = accept_language {
705 for tag in parse_accept_language(header) {
706 consider(&tag, &mut chain);
707 }
708 }
709 consider(default, &mut chain);
710 consider("en", &mut chain);
713 chain
714}
715
716fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
720 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
721 let serveable = state.catalogs.locales();
722 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
723 Translator::with_chain(chain, state.catalogs.clone())
724}
725
726#[derive(Serialize)]
734#[non_exhaustive]
735pub struct Resource {
736 pub base_path: String,
742 pub nav_label: Text,
744 pub list: list::ListConfig,
745 pub form: Option<form::FormConfig>,
746 pub permission: Option<String>,
753}
754
755#[derive(Clone, Serialize)]
760pub struct Permission {
761 pub code: String,
762 pub label: Text,
764 pub group: Text,
765}
766
767impl Resource {
768 pub fn new(
773 base_path: impl Into<String>,
774 nav_label: impl Into<Text>,
775 list: list::ListConfig,
776 ) -> Self {
777 Self {
778 base_path: base_path.into(),
779 nav_label: nav_label.into(),
780 list,
781 form: None,
782 permission: None,
783 }
784 }
785
786 pub fn form(mut self, form: form::FormConfig) -> Self {
789 self.form = Some(form);
790 self
791 }
792
793 pub fn permission(mut self, permission: impl Into<String>) -> Self {
795 self.permission = Some(permission.into());
796 self
797 }
798}
799
800fn builtin_permissions() -> Vec<Permission> {
803 vec![
804 Permission {
805 code: "backend.manage_users".to_string(),
806 label: "Manage backend users".into(),
807 group: "Backend".into(),
808 },
809 Permission {
810 code: "backend.manage_roles".to_string(),
811 label: "Manage roles".into(),
812 group: "Backend".into(),
813 },
814 Permission {
815 code: "backend.manage_branding".to_string(),
816 label: "Manage branding".into(),
817 group: "Backend".into(),
818 },
819 Permission {
820 code: plugins::MANAGE_PERMISSION.to_string(),
821 label: "Manage plugins".into(),
822 group: "Backend".into(),
823 },
824 Permission {
825 code: "backend.view_audit_log".to_string(),
826 label: "View the audit log".into(),
827 group: "Backend".into(),
828 },
829 ]
830}
831
832pub fn descriptor_sources() -> Vec<String> {
838 let mut out = Vec::new();
839 out.extend(laterite_core::collect_sources(&builtin_resources()));
840 out.extend(laterite_core::collect_sources(&builtin_settings()));
841 out.extend(laterite_core::collect_sources(&builtin_permissions()));
842 out.sort();
843 out.dedup();
844 out
845}
846
847pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
862 builtin_modules().iter().map(|m| m.migrations()).collect()
863}
864
865pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
869 vec![
870 Box::new(laterite_auth::AuthModule),
871 Box::new(settings::SettingsModule),
872 Box::new(plugins::PluginsModule),
873 ]
874}
875
876#[derive(Default)]
900pub struct Contributions {
901 pub resources: Vec<Resource>,
903 pub settings: Vec<settings::SettingsItem>,
905 pub permissions: Vec<Permission>,
907 pub picker_sources: Vec<picker::PickerSourceReg>,
909 pub persisters: Vec<persist::PersisterReg>,
911 pub listeners: Vec<laterite_core::ModelListenerReg>,
913 pub field_types: Vec<field::FieldTypeReg>,
917 pub column_types: Vec<list::ColumnTypeReg>,
918 pub screens: Vec<routes::ScreenReg>,
920 pub public_routes: Vec<routes::PublicRouteReg>,
922 pub plugin_defined: Arc<laterite_core::Registry>,
927}
928
929pub fn router(
930 auth: AuthService,
931 db: Db,
932 contributions: Contributions,
933 config: AdminConfig,
934 catalogs: Arc<CatalogStore>,
935) -> Router {
936 let Contributions {
937 resources: app_resources,
938 settings: app_settings,
939 permissions: app_permissions,
940 picker_sources: app_picker_sources,
941 persisters: app_persisters,
942 listeners: app_listeners,
943 field_types: app_field_types,
944 column_types: app_column_types,
945 screens: app_screens,
946 public_routes: app_public,
947 plugin_defined,
948 } = contributions;
949 let admin_path = normalize_path(&config.path);
950
951 let mut resources = builtin_resources();
952 let mut app_resources = app_resources;
953 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
957 prefix_resource(&admin_path, resource);
958 }
959
960 let mut settings = builtin_settings();
961 settings.extend(app_settings);
962 let mut permissions = builtin_permissions();
963 permissions.extend(app_permissions);
964
965 let mut nav = vec![NavLink {
969 label: "Dashboard".into(),
970 path: admin_path.clone(),
971 icon: Some("layout-dashboard"),
972 }];
973 for resource in &app_resources {
974 nav.push(NavLink {
975 label: resource.nav_label.clone(),
976 path: resource.base_path.clone(),
977 icon: None,
978 });
979 }
980 for reg in &app_screens {
983 if let Some(label) = ®.nav_label {
984 nav.push(NavLink {
985 label: label.clone(),
986 path: format!("{admin_path}{}", reg.base_path),
987 icon: None,
988 });
989 }
990 }
991 nav.push(NavLink {
992 label: "Settings".into(),
993 path: format!("{admin_path}/settings"),
994 icon: Some("settings"),
995 });
996 resources.extend(app_resources);
997
998 let app_name = if config.app_name.trim().is_empty() {
999 "Laterite".to_string()
1000 } else {
1001 config.app_name.clone()
1002 };
1003
1004 let mut pickers = picker::PickerRegistry::new();
1007 for reg in app_picker_sources {
1008 let name = reg.name.clone();
1009 if !field::is_name(&name, true) {
1010 panic!("invalid picker source name `{name}`");
1011 }
1012 if pickers.insert(name.clone(), reg).is_some() {
1013 panic!("duplicate picker source `{name}`");
1014 }
1015 }
1016
1017 let pickers = Arc::new(pickers);
1021 let mut field_types = field::builtin_registry();
1022 field_types.insert(
1023 "reference".to_string(),
1024 Arc::new(field::RefPickerField::new(pickers.clone())),
1025 );
1026
1027 let mut persisters = persist::PersisterRegistry::new();
1030 for reg in app_persisters {
1031 let name = reg.name.clone();
1032 if !field::is_name(&name, true) {
1033 panic!("invalid persister name `{name}`");
1034 }
1035 if persisters.insert(name.clone(), reg.persister).is_some() {
1036 panic!("duplicate persister `{name}`");
1037 }
1038 }
1039
1040 for reg in app_field_types {
1046 let name = reg.name().to_string();
1047 if !field::is_name(&name, true) {
1048 panic!("invalid field type name `{name}`");
1049 }
1050 if field_types.insert(name.clone(), reg.field_type).is_some() {
1051 panic!("duplicate field type `{name}`");
1052 }
1053 }
1054
1055 let mut column_types = list::builtin_column_registry();
1059 for reg in app_column_types {
1060 let name = reg.name().to_string();
1061 if !field::is_name(&name, true) {
1062 panic!("invalid column type name `{name}`");
1063 }
1064 if column_types.insert(name.clone(), reg.column_type).is_some() {
1065 panic!("duplicate column type `{name}`");
1066 }
1067 }
1068
1069 let settings = Arc::new(settings);
1070 let state = AdminState {
1071 auth,
1072 db,
1073 nav: Arc::new(nav),
1074 settings: settings.clone(),
1075 permissions: Arc::new(permissions),
1076 admin_path: Arc::from(admin_path.as_str()),
1077 secure_cookie: config.secure_cookie,
1078 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1079 origin: Arc::from(config.origin.trim_end_matches('/')),
1080 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1081 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1082 catalogs,
1083 app_name,
1084 brand_cache: Arc::new(RwLock::new(None)),
1085 field_types: Arc::new(field_types),
1086 column_types: Arc::new(column_types),
1087 plugin_defined,
1088 assets: Arc::new(builtin_assets()),
1089 asset_urls: Arc::new(asset_urls(&builtin_assets())),
1090 pickers,
1091 overrides: Arc::new(field::NoOverrides),
1092 };
1093
1094 let mut protected = Router::new().route(&admin_path, get(dashboard));
1095 for resource in &resources {
1096 protected = protected.merge(mount_resource(
1097 resource,
1098 &state.field_types,
1099 &persisters,
1100 &app_listeners,
1101 ));
1102 }
1103 for reg in &app_screens {
1107 let base = format!("{admin_path}{}", reg.base_path);
1108 let ctx = routes::RouteCtx::new(
1109 state.db.clone(),
1110 &base,
1111 &admin_path,
1112 &state.origin,
1113 state.plugin_defined.clone(),
1114 );
1115 protected = protected.merge(guard_with_permission(
1116 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1117 ®.permission,
1118 ));
1119 }
1120
1121 protected = protected.merge(guard_with_permission(
1124 Router::new()
1125 .route(
1126 &format!("{admin_path}/roles/new"),
1127 get(roles::new_form).post(roles::create),
1128 )
1129 .route(
1130 &format!("{admin_path}/roles/{{id}}/edit"),
1131 get(roles::edit_form).post(roles::update),
1132 ),
1133 "backend.manage_roles",
1134 ));
1135 protected = protected.merge(guard_with_permission(
1138 Router::new()
1139 .route(
1140 &format!("{admin_path}/users/{{id}}/edit"),
1141 get(users::edit_form).post(users::update),
1142 )
1143 .route(
1144 &format!("{admin_path}/users/{{id}}/active"),
1145 post(users::set_active),
1146 ),
1147 "backend.manage_users",
1148 ));
1149 protected = protected.merge(guard_with_permission(
1152 Router::new()
1153 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1154 .route(
1155 &format!("{admin_path}/plugins/toggle"),
1156 post(plugins::toggle),
1157 ),
1158 plugins::MANAGE_PERMISSION,
1159 ));
1160 protected = protected
1161 .route(&format!("{admin_path}/settings"), get(settings_index))
1162 .route(
1163 &format!("{admin_path}/preferences"),
1164 get(preferences_form).post(preferences_update),
1165 )
1166 .route(
1167 &format!("{admin_path}/preferences/sessions/revoke"),
1168 post(session_revoke),
1169 )
1170 .route(
1171 &format!("{admin_path}/preferences/sessions/revoke-others"),
1172 post(session_revoke_others),
1173 )
1174 .route(
1177 &format!("{admin_path}/pickers/{{source}}/search"),
1178 any(picker::search),
1179 )
1180 .route(
1181 &format!("{admin_path}/pickers/{{source}}/resolve"),
1182 any(picker::resolve),
1183 )
1184 .route(&format!("{admin_path}/logout"), post(logout));
1185
1186 for item in settings.iter().filter(|i| i.link.is_none()) {
1190 let path = format!("{admin_path}{}", item.route());
1191 let code = item.code.clone();
1192 let edit_code = code.clone();
1193 protected = protected.route(
1194 &path,
1195 get(
1196 move |State(state): State<AdminState>,
1197 Extension(shell): Extension<Shell>,
1198 Extension(user): Extension<AuthenticatedUser>| {
1199 let code = edit_code.clone();
1200 async move { settings_edit(state, shell, user, code).await }
1201 },
1202 )
1203 .post(
1204 move |State(state): State<AdminState>,
1205 Extension(shell): Extension<Shell>,
1206 Extension(user): Extension<AuthenticatedUser>,
1207 Extension(session): Extension<session::SessionHandle>,
1208 Form(data): Form<HashMap<String, String>>| {
1209 let code = code.clone();
1210 async move { settings_update(state, shell, user, session, code, data).await }
1211 },
1212 ),
1213 );
1214 }
1215
1216 protected
1217 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1218 .route(
1222 &format!("{admin_path}/login"),
1223 get(login_form).post(login_submit),
1224 )
1225 .route(
1226 &format!("{admin_path}/setup"),
1227 get(setup_form).post(setup_submit),
1228 )
1229 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1230 .merge(mount_public(
1234 &app_public,
1235 &state.db,
1236 &admin_path,
1237 &state.origin,
1238 &state.plugin_defined,
1239 ))
1240 .fallback(not_found_fallback)
1242 .layer(middleware::from_fn_with_state(
1245 state.clone(),
1246 enforce_origin,
1247 ))
1248 .layer(middleware::from_fn_with_state(
1251 state.clone(),
1252 capture_client,
1253 ))
1254 .layer(
1259 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1260 header::CACHE_CONTROL,
1261 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1262 ),
1263 )
1264 .layer(
1268 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1269 header::VARY,
1270 axum::http::HeaderValue::from_static("HX-Request"),
1271 ),
1272 )
1273 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1274 handle_panic,
1275 ))
1276 .with_state(state)
1277}
1278
1279async fn not_found_fallback() -> AdminError {
1281 AdminError::NotFound
1282}
1283
1284fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1286 let cause = err
1287 .downcast_ref::<&str>()
1288 .map(|s| s.to_string())
1289 .or_else(|| err.downcast_ref::<String>().cloned())
1290 .unwrap_or_else(|| "unknown panic".to_string());
1291 tracing::error!(panic = cause, "admin handler panicked");
1292 error::masked_500()
1293}
1294
1295pub fn normalize_path(path: &str) -> String {
1300 let trimmed = path.trim().trim_matches('/');
1301 if trimmed.is_empty() {
1302 "/admin".to_string()
1303 } else {
1304 format!("/{trimmed}")
1305 }
1306}
1307
1308fn mount_public(
1312 regs: &[routes::PublicRouteReg],
1313 db: &Db,
1314 admin_path: &str,
1315 origin: &str,
1316 plugin_defined: &Arc<laterite_core::Registry>,
1317) -> Router<AdminState> {
1318 let mut router = Router::new();
1319 for reg in regs {
1320 let ctx = routes::RouteCtx::new(
1321 db.clone(),
1322 ®.path,
1323 admin_path,
1324 origin,
1325 plugin_defined.clone(),
1326 );
1327 router = router.nest_service(
1332 ®.path,
1333 reg.route.mount(&ctx).fallback(not_found_fallback),
1334 );
1335 }
1336 router
1337}
1338
1339pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1342 let old = resource.base_path.clone();
1343 resource.base_path = base.to_string();
1344 if let Some(edit_base) = &mut resource.list.edit_base {
1345 if edit_base == &old {
1346 *edit_base = base.to_string();
1347 }
1348 }
1349 if let Some(form) = &mut resource.form {
1350 if form.base_path == old {
1351 form.base_path = base.to_string();
1352 }
1353 }
1354}
1355
1356fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1357 resource.base_path = format!("{admin_path}{}", resource.base_path);
1358 if let Some(edit_base) = &mut resource.list.edit_base {
1359 *edit_base = format!("{admin_path}{edit_base}");
1360 }
1361 if let Some(form) = &mut resource.form {
1362 form.base_path = format!("{admin_path}{}", form.base_path);
1363 }
1364}
1365
1366pub(crate) struct AdminAsset {
1368 pub mime: &'static str,
1369 pub bytes: &'static [u8],
1370}
1371
1372pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1375
1376pub(crate) type AssetUrls = HashMap<&'static str, String>;
1380
1381pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
1383 registry
1384 .iter()
1385 .map(|(&key, asset)| {
1386 (
1387 key,
1388 http_cache::fingerprint(key, &http_cache::digest(asset.bytes)),
1389 )
1390 })
1391 .collect()
1392}
1393
1394pub(crate) fn page_assets(
1401 keys: &[&str],
1402 base: &str,
1403 registry: &AssetRegistry,
1404 urls: &AssetUrls,
1405) -> Vec<PageAsset> {
1406 let mut seen = std::collections::HashSet::new();
1407 let mut out = Vec::new();
1408 for &key in keys {
1409 if !seen.insert(key) {
1410 continue;
1411 }
1412 match (registry.get(key), urls.get(key)) {
1413 (Some(asset), Some(path)) => out.push(PageAsset {
1414 url: format!("{base}/assets/{path}"),
1415 css: asset.mime.contains("css"),
1416 }),
1417 _ => debug_assert!(false, "asset key `{key}` is not registered"),
1418 }
1419 }
1420 out
1421}
1422
1423pub(crate) fn builtin_assets() -> AssetRegistry {
1432 let font = |bytes: &'static [u8]| AdminAsset {
1433 mime: "font/woff2",
1434 bytes,
1435 };
1436 HashMap::from([
1437 (
1438 "laterite.css",
1439 AdminAsset {
1440 mime: "text/css; charset=utf-8",
1441 bytes: include_bytes!("../assets/laterite.css"),
1442 },
1443 ),
1444 (
1445 "laterite.js",
1446 AdminAsset {
1447 mime: "text/javascript; charset=utf-8",
1448 bytes: include_bytes!("../assets/laterite.js"),
1449 },
1450 ),
1451 (
1452 "vendor/htmx.min.js",
1453 AdminAsset {
1454 mime: "text/javascript; charset=utf-8",
1455 bytes: include_bytes!("../assets/vendor/htmx.min.js"),
1456 },
1457 ),
1458 (
1459 "fields/ref-picker.js",
1460 AdminAsset {
1461 mime: "text/javascript; charset=utf-8",
1462 bytes: include_bytes!("../assets/fields/ref-picker.js"),
1463 },
1464 ),
1465 (
1466 "fields/ref-picker.css",
1467 AdminAsset {
1468 mime: "text/css; charset=utf-8",
1469 bytes: include_bytes!("../assets/fields/ref-picker.css"),
1470 },
1471 ),
1472 (
1473 "mark.svg",
1474 AdminAsset {
1475 mime: "image/svg+xml",
1476 bytes: include_bytes!("../assets/mark.svg"),
1477 },
1478 ),
1479 (
1480 "mark.png",
1481 AdminAsset {
1482 mime: "image/png",
1483 bytes: include_bytes!("../assets/mark.png"),
1484 },
1485 ),
1486 (
1487 "fonts/space-grotesk-500.woff2",
1488 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1489 ),
1490 (
1491 "fonts/space-grotesk-600.woff2",
1492 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1493 ),
1494 (
1495 "fonts/space-grotesk-700.woff2",
1496 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1497 ),
1498 (
1499 "fonts/ibm-plex-sans-400.woff2",
1500 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1501 ),
1502 (
1503 "fonts/ibm-plex-sans-600.woff2",
1504 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1505 ),
1506 (
1507 "fonts/ibm-plex-mono-400.woff2",
1508 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1509 ),
1510 (
1511 "fonts/ibm-plex-mono-600.woff2",
1512 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1513 ),
1514 ])
1515}
1516
1517async fn serve_asset(
1519 State(state): State<AdminState>,
1520 headers: HeaderMap,
1521 Path(path): Path<String>,
1522) -> Response {
1523 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
1527 let Some(asset) = state.assets.get(key.as_str()) else {
1528 return not_found();
1529 };
1530 let policy = if fingerprinted {
1531 http_cache::IMMUTABLE
1532 } else {
1533 http_cache::REVALIDATE
1534 };
1535 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
1536}
1537
1538fn mount_resource(
1543 resource: &Resource,
1544 field_types: &field::FieldRegistry,
1545 persisters: &persist::PersisterRegistry,
1546 listeners: &[laterite_core::ModelListenerReg],
1547) -> Router<AdminState> {
1548 if resource.form.is_some() && resource.permission.is_none() {
1553 panic!(
1554 "admin resource `{}` has a create/edit form but no permission; a write \
1555 resource must declare a permission",
1556 resource.base_path
1557 );
1558 }
1559 let base = resource.base_path.clone();
1560 let list_path = base.clone();
1561 let list_cfg = resource.list.clone();
1562 let mut router = Router::new().route(
1563 &base,
1564 get(
1565 move |State(state): State<AdminState>,
1566 Extension(shell): Extension<Shell>,
1567 Query(params): Query<list::ListParams>,
1568 Query(raw): Query<std::collections::HashMap<String, String>>,
1569 Extension(user): Extension<AuthenticatedUser>,
1570 headers: axum::http::HeaderMap| {
1571 let cfg = list_cfg.clone();
1572 let path = list_path.clone();
1573 async move {
1574 list::handle(&state, &cfg, &path, params, &raw, &user, shell, &headers).await
1575 }
1576 },
1577 ),
1578 );
1579
1580 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
1581 router =
1582 router.route(
1583 &format!("{base}/export"),
1584 get(
1585 move |state: State<AdminState>,
1586 Query(raw): Query<std::collections::HashMap<String, String>>,
1587 Extension(user): Extension<AuthenticatedUser>,
1588 Extension(shell): Extension<Shell>,
1589 Extension(session): Extension<session::SessionHandle>| {
1590 let cfg = export_cfg.clone();
1591 let path = export_path.clone();
1592 async move {
1593 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
1594 }
1595 },
1596 ),
1597 );
1598
1599 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
1600 router = router.route(
1601 &format!("{base}/columns"),
1602 post(
1603 move |State(state): State<AdminState>,
1604 Extension(user): Extension<AuthenticatedUser>,
1605 Extension(session): Extension<session::SessionHandle>,
1606 headers: axum::http::HeaderMap,
1607 Form(pairs): Form<Vec<(String, String)>>| {
1608 let cfg = columns_cfg.clone();
1609 let path = columns_path.clone();
1610 async move {
1611 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
1612 }
1613 },
1614 ),
1615 );
1616
1617 if resource.list.deletable {
1618 let persister: Arc<dyn persist::Persister> =
1622 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
1623 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
1624 panic!(
1625 "admin resource `{}`: unregistered persister `{key}`",
1626 resource.base_path
1627 )
1628 }),
1629 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
1630 };
1631 let ctx = Arc::new(bulk::BulkContext {
1632 base_path: resource.base_path.clone(),
1633 entity: resource.list.entity.clone(),
1634 persister,
1635 listeners: listeners
1636 .iter()
1637 .filter(|reg| reg.matches(&resource.list.entity))
1638 .map(|reg| reg.listener.clone())
1639 .collect(),
1640 });
1641 router =
1642 router.route(
1643 &format!("{base}/delete"),
1644 post(
1645 move |state: State<AdminState>,
1646 user: Extension<AuthenticatedUser>,
1647 session: Extension<session::SessionHandle>,
1648 headers: axum::http::HeaderMap,
1649 form: Form<Vec<(String, String)>>| {
1650 let ctx = ctx.clone();
1651 async move {
1652 bulk::delete_handler(state, user, session, ctx, headers, form).await
1653 }
1654 },
1655 ),
1656 );
1657 }
1658
1659 if let Some(form_cfg) = resource.form.clone() {
1660 let prepared = Arc::new(
1663 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
1664 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1665 );
1666 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1667 router = router.route(
1668 &format!("{base}/new"),
1669 get(
1670 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1671 let pf = new_pf.clone();
1672 async move { form::new_form(&state, &pf, shell) }
1673 },
1674 )
1675 .post(
1676 move |State(state): State<AdminState>,
1677 Extension(shell): Extension<Shell>,
1678 Extension(user): Extension<AuthenticatedUser>,
1679 Extension(session): Extension<session::SessionHandle>,
1680 headers: axum::http::HeaderMap,
1681 Form(data): Form<HashMap<String, String>>| {
1682 let pf = create_pf.clone();
1683 async move {
1684 form::create(&state, &pf, data, shell, &user, &session, &headers).await
1685 }
1686 },
1687 ),
1688 );
1689
1690 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1691 router = router.route(
1692 &format!("{base}/{{id}}/edit"),
1693 get(
1694 move |State(state): State<AdminState>,
1695 Extension(shell): Extension<Shell>,
1696 Path(id): Path<String>| {
1697 let pf = edit_pf.clone();
1698 async move { form::edit_form(&state, &pf, id, shell).await }
1699 },
1700 )
1701 .post(
1702 move |State(state): State<AdminState>,
1703 Extension(shell): Extension<Shell>,
1704 Extension(user): Extension<AuthenticatedUser>,
1705 Extension(session): Extension<session::SessionHandle>,
1706 Path(id): Path<String>,
1707 headers: axum::http::HeaderMap,
1708 Form(data): Form<HashMap<String, String>>| {
1709 let pf = update_pf.clone();
1710 async move {
1711 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
1712 }
1713 },
1714 ),
1715 );
1716 }
1717
1718 if let Some(permission) = &resource.permission {
1720 router = guard_with_permission(router, permission);
1721 }
1722 router
1723}
1724
1725fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
1730 let needed: Arc<str> = Arc::from(permission);
1731 router.route_layer(middleware::from_fn(
1732 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
1733 let needed = needed.clone();
1734 async move {
1735 if user.allows(&needed) {
1736 next.run(req).await
1737 } else {
1738 forbidden()
1739 }
1740 }
1741 },
1742 ))
1743}
1744
1745async fn require_auth(
1751 State(state): State<AdminState>,
1752 jar: CookieJar,
1753 request: Request,
1754 next: Next,
1755) -> Response {
1756 let login = format!("{}/login", state.admin_path);
1757 let client_ctx = request
1758 .extensions()
1759 .get::<RequestContext>()
1760 .cloned()
1761 .unwrap_or_default();
1762 let live = match jar.get(SESSION_COOKIE) {
1766 Some(cookie) => {
1767 let token = cookie.value().to_string();
1768 state
1769 .auth
1770 .resolve_session(&token)
1771 .await
1772 .ok()
1773 .map(|resolved| (token, resolved))
1774 }
1775 None => None,
1776 };
1777 let (token, resolved, recalled) = match live {
1778 Some((token, resolved)) => (token, resolved, None),
1779 None => match recall(&state, &jar, &client_ctx).await {
1780 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
1781 None => {
1784 return (
1785 jar.remove(remember_removal(&state.admin_path)),
1786 Redirect::to(&login),
1787 )
1788 .into_response()
1789 }
1790 },
1791 };
1792 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
1793
1794 let mut request = request;
1799 let safe = session::is_safe_method(request.method());
1800 let mut deferred: Option<session::CsrfPending> = None;
1802 if !safe {
1803 let (parts, body) = request.into_parts();
1804 let (body, submitted) = if session::is_multipart(&parts.headers) {
1811 let token = session::header_token(&parts.headers)
1812 .or_else(|| session::query_token(parts.uri.query()));
1813 if token.is_none() {
1818 deferred = Some(session::CsrfPending::default());
1819 }
1820 (body, token)
1821 } else {
1822 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
1823 .await
1824 .unwrap_or_default();
1825 let token = session::submitted_token(&parts.headers, &bytes);
1826 (Body::from(bytes), token)
1827 };
1828 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
1829 {
1830 tracing::warn!(
1831 user_id = resolved.identity.user.id,
1832 "admin CSRF check failed"
1833 );
1834 return error::csrf_rejected();
1835 }
1836 request = Request::from_parts(parts, body);
1837 }
1838
1839 let flash = if safe {
1842 handle.take_flash()
1843 } else {
1844 Vec::new()
1845 };
1846 let user = resolved.identity;
1847 let acting_user_id = user.user.id;
1849 let path = request.uri().path().to_string();
1850 let accept_language = request
1854 .headers()
1855 .get("accept-language")
1856 .and_then(|v| v.to_str().ok());
1857 let serveable = state.catalogs.locales();
1858 let chain = resolve_locale_chain(
1859 user.user.locale.as_deref(),
1860 accept_language,
1861 &state.default_locale,
1862 &serveable,
1863 );
1864 let i18n = Translator::with_chain(chain, state.catalogs.clone());
1865 let translator = i18n.clone();
1868 let (sidebar, active_nav) = resolve_nav_context(
1869 &state.nav,
1870 &state.settings,
1871 &state.admin_path,
1872 &user.permissions,
1873 &path,
1874 &i18n,
1875 );
1876 let brand = state.brand().await;
1877 let shell = Shell::new(
1878 &state.admin_path,
1879 brand,
1880 &state.nav,
1881 &user,
1882 state.timezone,
1883 sidebar,
1884 active_nav.as_deref(),
1885 handle.csrf_token(),
1886 flash,
1887 i18n,
1888 state.asset_urls.clone(),
1889 state.origin.to_string(),
1890 );
1891 request.extensions_mut().insert(user);
1892 request.extensions_mut().insert(shell);
1893 request.extensions_mut().insert(handle.clone());
1894 if let Some(pending) = &deferred {
1896 request.extensions_mut().insert(pending.clone());
1897 }
1898 let mut response = next.run(request).await;
1899
1900 if let Some(pending) = &deferred {
1905 if !pending.was_satisfied() {
1906 tracing::error!(
1907 user_id = acting_user_id,
1908 "a multipart route returned without verifying its request token"
1909 );
1910 return error::csrf_rejected();
1911 }
1912 }
1913
1914 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
1918 response = error::localized_error(meta.kind, &translator);
1919 }
1920
1921 if let Some(blob) = handle.dirty_blob() {
1924 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
1925 tracing::error!(error = %e, "persisting admin session failed");
1926 }
1927 }
1928 if let Some(credential) = recalled {
1931 let jar = jar
1932 .add(session_cookie(
1933 token,
1934 &state.admin_path,
1935 state.secure_cookie,
1936 ))
1937 .add(remember_cookie(
1938 credential,
1939 &state.admin_path,
1940 state.secure_cookie,
1941 ));
1942 return (jar, response).into_response();
1943 }
1944 response
1945}
1946
1947async fn recall(
1951 state: &AdminState,
1952 jar: &CookieJar,
1953 ctx: &RequestContext,
1954) -> Option<(
1955 String,
1956 laterite_auth::ResolvedSession,
1957 laterite_auth::RememberCredential,
1958)> {
1959 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
1960 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
1961 let token = recalled.session.token.clone();
1962 let resolved = state.auth.resolve_session(&token).await.ok()?;
1963 Some((token, resolved, recalled.remember))
1964}
1965
1966const MAX_FORM_BYTES: usize = 1024 * 1024;
1969
1970async fn capture_client(
1978 State(state): State<AdminState>,
1979 mut request: Request,
1980 next: Next,
1981) -> Response {
1982 let peer = request
1983 .extensions()
1984 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
1985 .map(|info| info.0);
1986 let headers = request.headers();
1987 let ctx = RequestContext {
1988 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
1989 user_agent: clientip::user_agent(headers),
1990 };
1991 request.extensions_mut().insert(ctx);
1992 next.run(request).await
1993}
1994
1995async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
1996 if !session::is_safe_method(request.method())
1997 && !session::origin_ok(request.headers(), &state.origin)
1998 {
1999 tracing::warn!(
2000 path = request.uri().path(),
2001 "admin CSRF origin check failed"
2002 );
2003 return error::csrf_rejected();
2004 }
2005 next.run(request).await
2006}
2007
2008async fn login_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2009 match state.auth.has_any_operator().await {
2011 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
2012 Ok(true) => render(LoginTemplate {
2013 base: state.admin_path.to_string(),
2014 asset_urls: state.asset_urls.clone(),
2015 brand: state.brand().await,
2016 error: None,
2017 i18n: pre_auth_translator(&state, &headers),
2018 }),
2019 Err(_) => render_error(),
2020 }
2021}
2022
2023#[derive(Deserialize)]
2024struct LoginForm {
2025 username: String,
2026 password: String,
2027 #[serde(default)]
2029 remember: Option<String>,
2030}
2031
2032async fn login_submit(
2033 State(state): State<AdminState>,
2034 jar: CookieJar,
2035 headers: HeaderMap,
2036 Extension(ctx): Extension<RequestContext>,
2037 Form(form): Form<LoginForm>,
2038) -> Response {
2039 match state
2040 .auth
2041 .authenticate(&form.username, &form.password, &ctx)
2042 .await
2043 {
2044 Ok(session) => {
2045 let wants_remember = form.remember.is_some();
2046 let user_id = session.user_id;
2047 let mut jar = jar.add(session_cookie(
2048 session.token,
2049 &state.admin_path,
2050 state.secure_cookie,
2051 ));
2052 if wants_remember {
2053 match state.auth.issue_remember(user_id).await {
2054 Ok(credential) => {
2055 jar = jar.add(remember_cookie(
2056 credential,
2057 &state.admin_path,
2058 state.secure_cookie,
2059 ));
2060 }
2061 Err(e) => {
2063 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
2064 }
2065 }
2066 }
2067 (jar, Redirect::to(&state.admin_path)).into_response()
2068 }
2069 Err(_) => {
2070 let i18n = pre_auth_translator(&state, &headers);
2071 render(LoginTemplate {
2072 base: state.admin_path.to_string(),
2073 asset_urls: state.asset_urls.clone(),
2074 brand: state.brand().await,
2075 error: Some(i18n.t(&t!("Invalid username or password."))),
2076 i18n,
2077 })
2078 }
2079 }
2080}
2081
2082fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
2091 Cookie::build((SESSION_COOKIE, token))
2092 .path(admin_path.to_string())
2093 .http_only(true)
2094 .secure(secure)
2095 .same_site(SameSite::Lax)
2096 .build()
2097}
2098
2099fn remember_cookie(
2102 credential: laterite_auth::RememberCredential,
2103 admin_path: &str,
2104 secure: bool,
2105) -> Cookie<'static> {
2106 let seconds = (credential.expires_at - chrono::Utc::now())
2107 .num_seconds()
2108 .max(0);
2109 Cookie::build((REMEMBER_COOKIE, credential.cookie))
2110 .path(admin_path.to_string())
2111 .http_only(true)
2112 .secure(secure)
2113 .same_site(SameSite::Lax)
2114 .max_age(cookie::time::Duration::seconds(seconds))
2115 .build()
2116}
2117
2118fn remember_removal(admin_path: &str) -> Cookie<'static> {
2120 Cookie::build((REMEMBER_COOKIE, ""))
2121 .path(admin_path.to_string())
2122 .build()
2123}
2124
2125#[derive(Deserialize)]
2126struct SetupForm {
2127 username: String,
2128 first_name: String,
2129 last_name: String,
2130 email: String,
2131 password: String,
2132 timezone: String,
2133}
2134
2135async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2138 match state.auth.has_any_operator().await {
2139 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2140 Ok(false) => render(setup_view(
2141 &state.admin_path,
2142 state.brand().await,
2143 state.timezone,
2144 None,
2145 pre_auth_translator(&state, &headers),
2146 state.asset_urls.clone(),
2147 )),
2148 Err(_) => render_error(),
2149 }
2150}
2151
2152async fn setup_submit(
2153 State(state): State<AdminState>,
2154 jar: CookieJar,
2155 headers: HeaderMap,
2156 Form(form): Form<SetupForm>,
2157) -> Response {
2158 match state.auth.has_any_operator().await {
2160 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2161 Ok(false) => {}
2162 Err(_) => return render_error(),
2163 }
2164
2165 let username = form.username.trim();
2166 let email = form.email.trim();
2167 let first_name = form.first_name.trim();
2168 let last_name = form.last_name.trim();
2169 let tz = form.timezone.trim();
2170 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
2171 {
2172 return render(setup_view(
2173 &state.admin_path,
2174 state.brand().await,
2175 state.timezone,
2176 Some(t!(
2177 "Username, first name, email, and password are all required."
2178 )),
2179 pre_auth_translator(&state, &headers),
2180 state.asset_urls.clone(),
2181 ));
2182 }
2183 if tz.parse::<Tz>().is_err() {
2185 return render(setup_view(
2186 &state.admin_path,
2187 state.brand().await,
2188 state.timezone,
2189 Some(t!("That is not a recognised timezone.")),
2190 pre_auth_translator(&state, &headers),
2191 state.asset_urls.clone(),
2192 ));
2193 }
2194
2195 let new = NewOperator {
2196 username,
2197 email,
2198 first_name,
2199 last_name: (!last_name.is_empty()).then_some(last_name),
2200 password: &form.password,
2201 timezone: Some(tz),
2202 };
2203 if state.auth.create_superuser(new).await.is_err() {
2204 return render(setup_view(
2205 &state.admin_path,
2206 state.brand().await,
2207 state.timezone,
2208 Some(t!(
2209 "Could not create the account. The username or email may already be taken."
2210 )),
2211 pre_auth_translator(&state, &headers),
2212 state.asset_urls.clone(),
2213 ));
2214 }
2215
2216 match state
2218 .auth
2219 .authenticate(username, &form.password, &RequestContext::default())
2220 .await
2221 {
2222 Ok(session) => {
2223 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
2224 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
2225 }
2226 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2227 }
2228}
2229
2230fn setup_view(
2233 admin_path: &str,
2234 brand: String,
2235 default_tz: Tz,
2236 error: Option<Text>,
2237 i18n: Translator,
2238 asset_urls: Arc<AssetUrls>,
2239) -> SetupTemplate {
2240 let default_name = default_tz.name();
2241 let zones = TZ_VARIANTS
2242 .iter()
2243 .filter(|tz| zone_offered(tz.name()))
2244 .map(|tz| TzOption {
2245 name: tz.name().to_string(),
2246 selected: tz.name() == default_name,
2247 })
2248 .collect();
2249 SetupTemplate {
2250 base: admin_path.to_string(),
2251 asset_urls,
2252 brand,
2253 zones,
2254 error: error.map(|e| i18n.t(&e)),
2255 i18n,
2256 }
2257}
2258
2259async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
2260 if let Some(cookie) = jar.get(SESSION_COOKIE) {
2261 let _ = state.auth.logout(cookie.value()).await;
2262 }
2263 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2266 let _ = state.auth.revoke_remember(cookie.value()).await;
2267 }
2268 let removal = Cookie::build((SESSION_COOKIE, ""))
2269 .path(state.admin_path.to_string())
2270 .build();
2271 (
2272 jar.remove(removal)
2273 .remove(remember_removal(&state.admin_path)),
2274 Redirect::to(&format!("{}/login", state.admin_path)),
2275 )
2276 .into_response()
2277}
2278
2279async fn dashboard(
2280 Extension(shell): Extension<Shell>,
2281 Extension(user): Extension<AuthenticatedUser>,
2282) -> Response {
2283 render(DashboardTemplate {
2284 username: user.user.username,
2285 shell,
2286 })
2287}
2288
2289fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
2292 match &item.permission {
2293 None => true,
2294 Some(p) => perms.allows(p),
2295 }
2296}
2297
2298fn visible_settings(
2302 items: &[settings::SettingsItem],
2303 perms: &PermissionSet,
2304) -> Vec<settings::SettingsItem> {
2305 items
2306 .iter()
2307 .filter(|item| operator_can_see(item, perms))
2308 .cloned()
2309 .collect()
2310}
2311
2312async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
2313 settings::index(shell)
2314}
2315
2316async fn settings_edit(
2317 state: AdminState,
2318 shell: Shell,
2319 user: AuthenticatedUser,
2320 code: String,
2321) -> Response {
2322 let items = visible_settings(&state.settings, &user.permissions);
2325 match items
2326 .iter()
2327 .find(|item| item.code == code && item.link.is_none())
2328 {
2329 Some(item) => settings::edit_form(&state, item, shell).await,
2330 None => not_found(),
2331 }
2332}
2333
2334async fn settings_update(
2335 state: AdminState,
2336 shell: Shell,
2337 user: AuthenticatedUser,
2338 session: session::SessionHandle,
2339 code: String,
2340 data: HashMap<String, String>,
2341) -> Response {
2342 let items = visible_settings(&state.settings, &user.permissions);
2343 match items
2344 .iter()
2345 .find(|item| item.code == code && item.link.is_none())
2346 {
2347 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
2348 None => not_found(),
2349 }
2350}
2351
2352async fn preferences_form(
2354 State(state): State<AdminState>,
2355 Extension(shell): Extension<Shell>,
2356 Extension(user): Extension<AuthenticatedUser>,
2357 jar: CookieJar,
2358) -> Response {
2359 let sessions = session_rows(&state, &user, &shell, &jar).await;
2360 render(preferences_view(
2361 &shell,
2362 &user,
2363 state.timezone,
2364 &state.default_locale,
2365 &offered_locales(&state.catalogs),
2366 sessions,
2367 None,
2368 ))
2369}
2370
2371#[derive(Deserialize)]
2372struct RevokeSessionForm {
2373 id: String,
2374}
2375
2376async fn session_revoke(
2378 State(state): State<AdminState>,
2379 Extension(user): Extension<AuthenticatedUser>,
2380 Extension(session): Extension<session::SessionHandle>,
2381 Form(form): Form<RevokeSessionForm>,
2382) -> Response {
2383 match state.auth.revoke_session(user.user.id, &form.id).await {
2384 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
2385 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
2386 Err(e) => {
2387 tracing::error!(error = %e, "revoking a session failed");
2388 session.push_flash(
2389 FlashLevel::Error,
2390 t!("That session could not be signed out."),
2391 );
2392 }
2393 }
2394 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2395}
2396
2397async fn session_revoke_others(
2399 State(state): State<AdminState>,
2400 Extension(user): Extension<AuthenticatedUser>,
2401 Extension(session): Extension<session::SessionHandle>,
2402 jar: CookieJar,
2403) -> Response {
2404 let token = jar
2405 .get(SESSION_COOKIE)
2406 .map(|c| c.value().to_string())
2407 .unwrap_or_default();
2408 match state.auth.sign_out_everywhere(user.user.id, &token).await {
2409 Ok(_) => session.push_flash(
2410 FlashLevel::Success,
2411 t!("Every other session was signed out."),
2412 ),
2413 Err(e) => {
2414 tracing::error!(error = %e, "signing out other sessions failed");
2415 session.push_flash(
2416 FlashLevel::Error,
2417 t!("Those sessions could not be signed out."),
2418 );
2419 }
2420 }
2421 (
2424 jar.remove(remember_removal(&state.admin_path)),
2425 Redirect::to(&format!("{}/preferences", state.admin_path)),
2426 )
2427 .into_response()
2428}
2429
2430#[derive(Deserialize)]
2431struct PreferencesForm {
2432 timezone: String,
2433 #[serde(default)]
2435 locale: String,
2436}
2437
2438async fn preferences_update(
2439 State(state): State<AdminState>,
2440 Extension(shell): Extension<Shell>,
2441 Extension(user): Extension<AuthenticatedUser>,
2442 Extension(session): Extension<session::SessionHandle>,
2443 jar: CookieJar,
2444 Form(form): Form<PreferencesForm>,
2445) -> Response {
2446 let offered = offered_locales(&state.catalogs);
2447 let tz = form.timezone.trim();
2449 let tz_stored = if tz.is_empty() {
2450 None
2451 } else if tz.parse::<Tz>().is_ok() {
2452 Some(tz)
2453 } else {
2454 return render(preferences_view(
2455 &shell,
2456 &user,
2457 state.timezone,
2458 &state.default_locale,
2459 &offered,
2460 session_rows(&state, &user, &shell, &jar).await,
2461 Some(t!("That is not a recognised timezone.")),
2462 ));
2463 };
2464 let loc = form.locale.trim();
2465 let loc_stored = if loc.is_empty() {
2466 None
2467 } else if offered.iter().any(|l| l == loc) {
2468 Some(loc)
2469 } else {
2470 return render(preferences_view(
2471 &shell,
2472 &user,
2473 state.timezone,
2474 &state.default_locale,
2475 &offered,
2476 session_rows(&state, &user, &shell, &jar).await,
2477 Some(t!("That is not a supported language.")),
2478 ));
2479 };
2480 if state
2481 .auth
2482 .set_user_timezone(user.user.id, tz_stored)
2483 .await
2484 .is_err()
2485 {
2486 return render_error();
2487 }
2488 match state.auth.set_user_locale(user.user.id, loc_stored).await {
2489 Ok(()) => {
2490 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
2491 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2492 }
2493 Err(_) => render_error(),
2494 }
2495}
2496
2497fn preferences_view(
2501 shell: &Shell,
2502 user: &AuthenticatedUser,
2503 default_tz: Tz,
2504 default_locale: &str,
2505 offered: &[String],
2506 sessions: Vec<SessionRow>,
2507 error: Option<Text>,
2508) -> PreferencesTemplate {
2509 let current = user.user.timezone.as_deref();
2510 let zones = TZ_VARIANTS
2511 .iter()
2512 .filter(|tz| zone_offered(tz.name()))
2513 .map(|tz| TzOption {
2514 name: tz.name().to_string(),
2515 selected: current == Some(tz.name()),
2516 })
2517 .collect();
2518 let current_locale = user.user.locale.as_deref();
2519 let locales = offered
2520 .iter()
2521 .map(|code| LocaleOption {
2522 name: locale_name(code),
2523 selected: current_locale == Some(code.as_str()),
2524 code: code.clone(),
2525 })
2526 .collect();
2527 PreferencesTemplate {
2528 shell: shell.clone(),
2529 zones,
2530 effective_tz: shell.tz.name().to_string(),
2531 default_tz: default_tz.name().to_string(),
2532 inherits: current.is_none(),
2533 locales,
2534 default_locale: locale_name(default_locale),
2535 inherits_locale: current_locale.is_none(),
2536 sessions,
2537 error: error.map(|e| shell.tt(&e)),
2538 }
2539}
2540
2541async fn session_rows(
2544 state: &AdminState,
2545 user: &AuthenticatedUser,
2546 shell: &Shell,
2547 jar: &CookieJar,
2548) -> Vec<SessionRow> {
2549 let token = jar
2550 .get(SESSION_COOKIE)
2551 .map(|c| c.value().to_string())
2552 .unwrap_or_default();
2553 let locale = list::date_locale(shell.locale());
2554 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
2555 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
2556 };
2557 match state.auth.list_sessions(user.user.id, &token).await {
2558 Ok(sessions) => sessions
2559 .into_iter()
2560 .map(|s| SessionRow {
2561 id: s.id,
2562 started: stamp(s.created_at),
2563 last_seen: stamp(s.last_seen_at),
2564 expires: stamp(s.expires_at),
2565 device: clientip::describe(s.user_agent.as_deref())
2566 .or(s.user_agent)
2567 .unwrap_or_else(|| "-".to_string()),
2568 ip_address: s.ip_address.unwrap_or_default(),
2569 current: s.current,
2570 })
2571 .collect(),
2572 Err(e) => {
2573 tracing::error!(error = %e, "listing sessions failed");
2574 Vec::new()
2575 }
2576 }
2577}
2578
2579fn builtin_resources() -> Vec<Resource> {
2581 vec![
2582 Resource {
2583 base_path: "/users".to_string(),
2584 nav_label: "Backend Users".into(),
2585 list: backend_users_list_config(),
2586 form: None,
2587 permission: Some("backend.manage_users".to_string()),
2588 },
2589 Resource {
2590 base_path: "/roles".to_string(),
2591 nav_label: "Roles".into(),
2592 list: roles_list_config(),
2593 form: None,
2596 permission: Some("backend.manage_roles".to_string()),
2597 },
2598 Resource {
2599 base_path: "/audit-log".to_string(),
2600 nav_label: "Audit Log".into(),
2601 list: audit_log_list_config(),
2602 form: None,
2604 permission: Some("backend.view_audit_log".to_string()),
2605 },
2606 ]
2607}
2608
2609fn builtin_settings() -> Vec<settings::SettingsItem> {
2613 vec![
2614 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
2615 .description("Manage backend administrator accounts.")
2616 .category("Users")
2617 .order(10)
2618 .icon("users")
2619 .permission("backend.manage_users")
2620 .link("/users"),
2621 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
2622 .description("Manage roles and their permissions.")
2623 .category("Users")
2624 .order(20)
2625 .icon("shield")
2626 .permission("backend.manage_roles")
2627 .link("/roles"),
2628 settings::brand::settings_item(),
2629 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
2630 .description("Enable or disable installed plugins.")
2631 .category("System")
2632 .order(10)
2633 .icon("plug")
2634 .permission(plugins::MANAGE_PERMISSION)
2635 .link("/plugins"),
2636 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
2637 .description("Review the record of administrative changes.")
2638 .category("System")
2639 .order(20)
2640 .icon("history")
2641 .permission("backend.view_audit_log")
2642 .link("/audit-log"),
2643 ]
2644}
2645
2646fn backend_users_list_config() -> list::ListConfig {
2647 list::ListConfig {
2648 entity: "backend_users".to_string(),
2649 title: "Backend Users".into(),
2650 columns: vec![
2651 list::ListColumn::new("username", "Username"),
2652 list::ListColumn::new("email", "Email"),
2653 list::ListColumn::new("first_name", "First name"),
2654 list::ListColumn::new("last_name", "Last name"),
2655 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
2656 list::ListColumn::new("is_active", "Active").yes_no(),
2657 list::ListColumn::new("created_at", "Created").datetime(),
2658 ],
2659 order_by: "created_at".to_string(),
2660 edit_base: Some("/users".to_string()),
2663 filters: vec![
2664 list::ListFilter::boolean("is_active", "Active"),
2665 list::ListFilter::boolean("is_superuser", "Superuser"),
2666 ],
2667 ..Default::default()
2671 }
2672}
2673
2674fn roles_list_config() -> list::ListConfig {
2675 list::ListConfig {
2676 entity: "backend_roles".to_string(),
2677 title: "Roles".into(),
2678 columns: vec![
2679 list::ListColumn::new("code", "Code"),
2680 list::ListColumn::new("name", "Name"),
2681 list::ListColumn::new("created_at", "Created").datetime(),
2682 ],
2683 order_by: "created_at".to_string(),
2684 edit_base: Some("/roles".to_string()),
2685 creatable: true,
2686 deletable: true,
2687 ..Default::default()
2688 }
2689}
2690
2691fn audit_log_list_config() -> list::ListConfig {
2694 list::ListConfig {
2695 entity: "backend_audit_log".to_string(),
2696 title: "Audit Log".into(),
2697 columns: vec![
2698 list::ListColumn::new("created_at", "When").datetime(),
2699 list::ListColumn::new("actor_username", "Operator"),
2700 list::ListColumn::new("action", "Action"),
2701 list::ListColumn::new("target_type", "Target"),
2702 list::ListColumn::new("target_id", "Target ID"),
2703 ],
2704 order_by: "created_at".to_string(),
2705 per_page: 50,
2706 ..Default::default()
2708 }
2709}
2710
2711#[derive(Template)]
2712#[template(path = "login.html")]
2713struct LoginTemplate {
2714 base: String,
2716 asset_urls: Arc<AssetUrls>,
2718 brand: String,
2719 error: Option<String>,
2720 i18n: Translator,
2723}
2724
2725fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
2728 match urls.get(key) {
2729 Some(path) => format!("{base}/assets/{path}"),
2730 None => {
2731 debug_assert!(false, "asset key `{key}` is not registered");
2732 format!("{base}/assets/{key}")
2733 }
2734 }
2735}
2736
2737impl LoginTemplate {
2738 fn asset(&self, key: &str) -> String {
2739 preauth_asset(&self.asset_urls, &self.base, key)
2740 }
2741 fn t(&self, source: &str) -> String {
2742 self.i18n.t(&Text::dynamic(source))
2743 }
2744 fn locale(&self) -> &str {
2745 self.i18n.locale()
2746 }
2747}
2748
2749#[derive(Template)]
2750#[template(path = "dashboard.html")]
2751struct DashboardTemplate {
2752 shell: Shell,
2753 username: String,
2754}
2755
2756#[derive(Template)]
2757#[template(path = "setup.html")]
2758struct SetupTemplate {
2759 base: String,
2761 asset_urls: Arc<AssetUrls>,
2763 brand: String,
2764 zones: Vec<TzOption>,
2765 error: Option<String>,
2766 i18n: Translator,
2768}
2769
2770impl SetupTemplate {
2771 fn asset(&self, key: &str) -> String {
2772 preauth_asset(&self.asset_urls, &self.base, key)
2773 }
2774 fn t(&self, source: &str) -> String {
2775 self.i18n.t(&Text::dynamic(source))
2776 }
2777 fn locale(&self) -> &str {
2778 self.i18n.locale()
2779 }
2780}
2781
2782#[derive(Template)]
2783#[template(path = "preferences.html")]
2784struct PreferencesTemplate {
2785 shell: Shell,
2786 zones: Vec<TzOption>,
2787 effective_tz: String,
2789 default_tz: String,
2791 inherits: bool,
2793 locales: Vec<LocaleOption>,
2794 default_locale: String,
2796 inherits_locale: bool,
2798 sessions: Vec<SessionRow>,
2800 error: Option<String>,
2801}
2802
2803struct SessionRow {
2805 id: String,
2806 started: String,
2807 last_seen: String,
2808 expires: String,
2809 device: String,
2812 ip_address: String,
2814 current: bool,
2815}
2816
2817struct TzOption {
2818 name: String,
2819 selected: bool,
2820}
2821
2822struct LocaleOption {
2823 code: String,
2825 name: String,
2827 selected: bool,
2828}
2829
2830#[derive(Clone)]
2831struct NavView {
2832 label: String,
2833 path: String,
2834 active: bool,
2835 icon: &'static str,
2838}
2839
2840pub(crate) fn render<T: Template>(template: T) -> Response {
2842 match template.render() {
2843 Ok(html) => Html(html).into_response(),
2844 Err(e) => AdminError::from(e).into_response(),
2846 }
2847}
2848
2849pub(crate) fn render_error() -> Response {
2850 error::masked_500()
2851}
2852
2853pub(crate) fn not_found() -> Response {
2854 AdminError::NotFound.into_response()
2855}
2856
2857pub(crate) fn forbidden() -> Response {
2858 AdminError::Forbidden.into_response()
2859}
2860
2861#[cfg(test)]
2862mod tests {
2863 use super::*;
2864
2865 fn write_form() -> form::FormConfig {
2866 form::FormConfig {
2867 entity: "widgets".to_string(),
2868 title: "Widgets".into(),
2869 base_path: "/widgets".to_string(),
2870 id_field: "id".to_string(),
2871 fields: vec![],
2872 persist: None,
2873 timestamps: false,
2874 }
2875 }
2876
2877 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
2878 Resource {
2879 base_path: "/widgets".to_string(),
2880 nav_label: "Widgets".into(),
2881 list: backend_users_list_config(),
2882 form,
2883 permission: permission.map(str::to_string),
2884 }
2885 }
2886
2887 #[test]
2888 #[should_panic(expected = "must declare a permission")]
2889 fn write_resource_without_permission_aborts_boot() {
2890 let resource = resource_with(Some(write_form()), None);
2893 let _ = mount_resource(
2894 &resource,
2895 &field::builtin_registry(),
2896 &persist::PersisterRegistry::new(),
2897 &[],
2898 );
2899 }
2900
2901 #[test]
2902 fn read_only_resource_may_omit_permission() {
2903 let resource = resource_with(None, None);
2905 let _ = mount_resource(
2906 &resource,
2907 &field::builtin_registry(),
2908 &persist::PersisterRegistry::new(),
2909 &[],
2910 );
2911 }
2912
2913 #[test]
2914 fn shell_localizes_through_the_translator_and_falls_back() {
2915 let shell = Shell::test();
2917 assert_eq!(shell.t("Save"), "Save");
2918 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
2919 }
2920
2921 #[test]
2922 fn shell_tf_interpolates_integer_arguments() {
2923 let shell = Shell::test();
2924 assert_eq!(
2925 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
2926 "Page 2 of 5"
2927 );
2928 }
2929
2930 #[test]
2931 fn display_tz_prefers_a_valid_operator_preference() {
2932 assert_eq!(
2933 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
2934 Tz::Asia__Kolkata
2935 );
2936 }
2937
2938 #[test]
2939 fn display_tz_falls_back_when_unset_or_invalid() {
2940 let default = Tz::Europe__London;
2941 assert_eq!(resolve_display_tz(None, default), default);
2943 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
2945 assert_eq!(resolve_display_tz(Some(""), default), default);
2946 }
2947
2948 #[test]
2949 fn accept_language_orders_by_weight_and_drops_wildcard() {
2950 assert_eq!(
2951 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
2952 vec!["en-US", "en", "kn"]
2953 );
2954 assert_eq!(
2956 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
2957 vec!["kn", "en"]
2958 );
2959 }
2960
2961 #[test]
2962 fn locale_chain_prefers_operator_then_header_then_default() {
2963 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
2965 assert_eq!(
2968 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
2969 vec!["kn", "ta", "hi", "en"]
2970 );
2971 assert_eq!(
2973 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
2974 vec!["kn", "en", "ta"]
2975 );
2976 assert_eq!(
2978 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
2979 vec!["kn", "en"]
2980 );
2981 }
2982
2983 #[test]
2984 fn locale_chain_skips_unserveable_and_defaults_to_en() {
2985 let loaded = ["hi".to_string()];
2987 assert_eq!(
2990 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
2991 vec!["en"]
2992 );
2993 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
2995 assert_eq!(
2997 resolve_locale_chain(Some("hi"), None, "en", &loaded),
2998 vec!["hi", "en"]
2999 );
3000 assert_eq!(
3002 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
3003 vec![laterite_core::PSEUDO_LOCALE, "en"]
3004 );
3005 }
3006
3007 #[test]
3008 fn page_assets_dedupe_preserve_order_and_classify() {
3009 let mut reg = AssetRegistry::new();
3010 reg.insert(
3011 "a.js",
3012 AdminAsset {
3013 mime: "text/javascript; charset=utf-8",
3014 bytes: b"//",
3015 },
3016 );
3017 reg.insert(
3018 "b.css",
3019 AdminAsset {
3020 mime: "text/css; charset=utf-8",
3021 bytes: b"body{}",
3022 },
3023 );
3024 let urls = asset_urls(®);
3027 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
3028 assert_eq!(assets.len(), 2);
3029 assert_eq!(
3030 assets[0].url,
3031 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
3032 );
3033 assert!(!assets[0].css);
3034 assert_eq!(
3035 assets[1].url,
3036 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
3037 );
3038 assert!(assets[1].css);
3039 assert_ne!(assets[0].url, assets[1].url);
3042 }
3043
3044 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
3048 laterite_core::testing::connect_test(&[]).await
3049 }
3050
3051 #[tokio::test]
3052 async fn builtin_migrations_create_the_admin_tables() {
3053 let (db, _guard) = empty_db().await;
3054 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
3055 .await
3056 .unwrap();
3057 for table in ["backend_users", "settings"] {
3061 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
3062 .fetch_optional(&db.pool)
3063 .await;
3064 assert!(
3065 probe.is_ok(),
3066 "{table} should exist after builtin_migrations"
3067 );
3068 }
3069 }
3070
3071 #[tokio::test]
3072 async fn brand_setting_overrides_config_and_blank_falls_back() {
3073 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
3074 let state = AdminState {
3075 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3076 db: db.clone(),
3077 nav: Arc::new(Vec::new()),
3078 settings: Arc::new(Vec::new()),
3079 permissions: Arc::new(builtin_permissions()),
3080 trusted_proxies: Arc::new(Vec::new()),
3081 admin_path: Arc::from("/admin"),
3082 secure_cookie: false,
3083 origin: Arc::from(""),
3084 timezone: Tz::UTC,
3085 default_locale: Arc::from("en"),
3086 catalogs: Arc::new(CatalogStore::default()),
3087 app_name: "Configured Name".to_string(),
3088 brand_cache: Arc::new(RwLock::new(None)),
3089 field_types: Arc::new(field::builtin_registry()),
3090 column_types: Arc::new(list::builtin_column_registry()),
3091 plugin_defined: Arc::new(laterite_core::Registry::new()),
3092 assets: Arc::new(builtin_assets()),
3093 asset_urls: Arc::new(asset_urls(&builtin_assets())),
3094 pickers: Arc::new(picker::PickerRegistry::new()),
3095 overrides: Arc::new(field::NoOverrides),
3096 };
3097
3098 assert_eq!(state.brand().await, "Configured Name");
3100
3101 settings::store::save(
3104 &db,
3105 &settings::BrandSetting {
3106 app_name: "Acme Corp".to_string(),
3107 },
3108 )
3109 .await
3110 .unwrap();
3111 state.invalidate_brand();
3112 assert_eq!(state.brand().await, "Acme Corp");
3113
3114 settings::store::save(
3116 &db,
3117 &settings::BrandSetting {
3118 app_name: " ".to_string(),
3119 },
3120 )
3121 .await
3122 .unwrap();
3123 state.invalidate_brand();
3124 assert_eq!(state.brand().await, "Configured Name");
3125 }
3126
3127 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
3128 settings::SettingsItem {
3129 code: code.to_string(),
3130 label: code.into(),
3131 description: String::new().into(),
3132 category: "General".into(),
3133 hint: None,
3134 order: 1,
3135 icon: None,
3136 permission: permission.map(str::to_string),
3137 link: None,
3138 fields: Vec::new(),
3139 route: None,
3140 }
3141 }
3142
3143 #[test]
3144 fn settings_visibility_respects_permissions() {
3145 let items = vec![
3146 settings_item("public", None),
3147 settings_item("gated", Some("backend.manage_users")),
3148 ];
3149
3150 let none = PermissionSet::new(false, Vec::<String>::new());
3152 let codes: Vec<String> = visible_settings(&items, &none)
3153 .into_iter()
3154 .map(|i| i.code)
3155 .collect();
3156 assert_eq!(codes, ["public"]);
3157
3158 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
3160 assert_eq!(visible_settings(&items, &granted).len(), 2);
3161
3162 let superuser = PermissionSet::new(true, Vec::<String>::new());
3164 assert_eq!(visible_settings(&items, &superuser).len(), 2);
3165 }
3166
3167 #[test]
3168 fn context_sidebar_follows_settings_links() {
3169 let items = builtin_settings();
3170 let superuser = PermissionSet::new(true, Vec::<String>::new());
3171 let tr = Translator::new("en");
3172 let sidebar =
3173 |path: &str| resolve_nav_context(&[], &items, "/admin", &superuser, path, &tr).0;
3174 let active_path = |path: &str| -> Option<String> {
3175 sidebar(path)
3176 .into_iter()
3177 .flat_map(|g| g.items)
3178 .find(|i| i.active)
3179 .map(|i| i.path)
3180 };
3181
3182 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
3184 assert_eq!(
3185 active_path("/admin/roles/42/edit").as_deref(),
3186 Some("/admin/roles")
3187 );
3188 assert!(!sidebar("/admin/settings").is_empty());
3190 assert_eq!(active_path("/admin/settings"), None);
3191 assert_eq!(
3196 active_path("/admin/settings/branding").as_deref(),
3197 Some("/admin/settings/branding")
3198 );
3199 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
3202 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
3203 assert!(sidebar("/admin").is_empty());
3205 }
3206
3207 #[test]
3208 fn active_nav_lights_the_right_tab() {
3209 let nav = vec![
3210 NavLink {
3211 label: "Dashboard".into(),
3212 path: "/admin".to_string(),
3213 icon: Some("layout-dashboard"),
3214 },
3215 NavLink {
3216 label: "Pages".into(),
3217 path: "/admin/pages".to_string(),
3218 icon: None,
3219 },
3220 NavLink {
3221 label: "Settings".into(),
3222 path: "/admin/settings".to_string(),
3223 icon: Some("settings"),
3224 },
3225 ];
3226
3227 assert_eq!(
3229 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
3230 Some("/admin")
3231 );
3232 assert_eq!(
3234 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
3235 Some("/admin/pages")
3236 );
3237 assert_eq!(
3239 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
3240 None
3241 );
3242 assert_eq!(
3245 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
3246 None
3247 );
3248 assert_eq!(
3251 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
3252 Some("/admin/settings")
3253 );
3254 assert_eq!(
3255 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
3256 Some("/admin/settings")
3257 );
3258 }
3259}
3260
3261#[cfg(test)]
3262mod session_cookie_tests {
3263 use super::*;
3264
3265 #[test]
3269 fn the_session_cookie_never_outlives_the_browser() {
3270 let cookie = session_cookie("tok".into(), "/admin", false);
3271 assert_eq!(cookie.max_age(), None);
3272 assert!(cookie.http_only().unwrap_or(false));
3273 assert_eq!(cookie.path(), Some("/admin"));
3274 }
3275
3276 #[test]
3279 fn the_remember_cookie_carries_its_own_lifetime() {
3280 let credential = laterite_auth::RememberCredential {
3281 cookie: "sel:ver".into(),
3282 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
3283 };
3284 let cookie = remember_cookie(credential, "/admin", true);
3285 let age = cookie.max_age().expect("a remember cookie must persist");
3286 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
3288 assert!(cookie.http_only().unwrap_or(false));
3289 assert!(cookie.secure().unwrap_or(false));
3290 }
3291
3292 #[test]
3294 fn the_removal_matches_the_cookie_it_clears() {
3295 let removal = remember_removal("/admin");
3296 assert_eq!(removal.name(), REMEMBER_COOKIE);
3297 assert_eq!(removal.path(), Some("/admin"));
3298 }
3299}