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)]
785#[non_exhaustive]
786pub struct Permission {
787 pub code: String,
788 pub label: Text,
790 pub group: Text,
791 pub default_roles: Vec<String>,
794}
795
796pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
797
798impl Permission {
799 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
802 Self {
803 code: code.into(),
804 label: label.into(),
805 group: group.into(),
806 default_roles: Vec::new(),
807 }
808 }
809
810 pub fn roles<I, S>(mut self, roles: I) -> Self
813 where
814 I: IntoIterator<Item = S>,
815 S: Into<String>,
816 {
817 self.default_roles = roles.into_iter().map(Into::into).collect();
818 self
819 }
820}
821
822pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
828 let permissions: Vec<Permission> = builtin_permissions()
831 .into_iter()
832 .chain(contributed.iter().cloned())
833 .collect();
834 let held = |role: &str| -> Vec<String> {
835 permissions
836 .iter()
837 .filter(|p| {
838 if p.default_roles.is_empty() {
839 role == ROLE_ADMIN
840 } else {
841 p.default_roles.iter().any(|r| r == role)
842 }
843 })
844 .map(|p| p.code.clone())
845 .collect()
846 };
847 vec![
848 laterite_auth::store::SystemRole {
849 code: ROLE_ADMIN,
850 name: "Administrator",
851 description: "Administers the panel itself: operators, roles and settings.",
852 permissions: held(ROLE_ADMIN),
853 },
854 laterite_auth::store::SystemRole {
855 code: ROLE_EDITOR,
856 name: "Editor",
857 description: "Works with the content the application declares.",
858 permissions: held(ROLE_EDITOR),
859 },
860 ]
861}
862
863impl Resource {
864 pub fn new(
869 base_path: impl Into<String>,
870 nav_label: impl Into<Text>,
871 list: list::ListConfig,
872 ) -> Self {
873 Self {
874 base_path: base_path.into(),
875 nav_label: nav_label.into(),
876 list,
877 form: None,
878 permission: None,
879 }
880 }
881
882 pub fn form(mut self, form: form::FormConfig) -> Self {
885 self.form = Some(form);
886 self
887 }
888
889 pub fn permission(mut self, permission: impl Into<String>) -> Self {
891 self.permission = Some(permission.into());
892 self
893 }
894}
895
896struct SystemRoleGuard;
902
903#[laterite_core::strata::async_trait]
904impl laterite_core::ModelListener for SystemRoleGuard {
905 async fn before_delete(
906 &self,
907 cx: &mut laterite_core::SaveCx<'_>,
908 rec: &laterite_core::Record,
909 ) -> Result<(), Text> {
910 let Some(id) = rec.id() else {
911 return Ok(());
912 };
913 let backend = cx.backend();
915 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
916 Ok(true) => Err(t!(
917 "The framework maintains this role; it cannot be deleted."
918 )),
919 Err(_) => Err(t!(
921 "Could not check whether this role is maintained by the framework."
922 )),
923 Ok(false) => Ok(()),
924 }
925 }
926}
927
928fn builtin_permissions() -> Vec<Permission> {
931 vec![
934 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
935 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
936 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
937 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
938 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
939 ]
940}
941
942pub fn descriptor_sources() -> Vec<String> {
948 let mut out = Vec::new();
949 out.extend(laterite_core::collect_sources(&builtin_resources()));
950 out.extend(laterite_core::collect_sources(&builtin_settings()));
951 out.extend(laterite_core::collect_sources(&builtin_permissions()));
952 out.sort();
953 out.dedup();
954 out
955}
956
957pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
972 builtin_modules().iter().map(|m| m.migrations()).collect()
973}
974
975pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
979 vec![
980 Box::new(laterite_auth::AuthModule),
981 Box::new(settings::SettingsModule),
982 Box::new(plugins::PluginsModule),
983 ]
984}
985
986#[derive(Default)]
1010pub struct Contributions {
1011 pub resources: Vec<Resource>,
1013 pub settings: Vec<settings::SettingsItem>,
1015 pub permissions: Vec<Permission>,
1017 pub picker_sources: Vec<picker::PickerSourceReg>,
1019 pub persisters: Vec<persist::PersisterReg>,
1021 pub listeners: Vec<laterite_core::ModelListenerReg>,
1023 pub field_types: Vec<field::FieldTypeReg>,
1027 pub column_types: Vec<list::ColumnTypeReg>,
1028 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1031 pub screens: Vec<routes::ScreenReg>,
1033 pub public_routes: Vec<routes::PublicRouteReg>,
1035 pub plugin_defined: Arc<laterite_core::Registry>,
1040}
1041
1042pub fn router(
1043 auth: AuthService,
1044 db: Db,
1045 contributions: Contributions,
1046 config: AdminConfig,
1047 catalogs: Arc<CatalogStore>,
1048) -> Router {
1049 let Contributions {
1050 resources: app_resources,
1051 settings: app_settings,
1052 permissions: app_permissions,
1053 picker_sources: app_picker_sources,
1054 persisters: app_persisters,
1055 listeners: app_listeners,
1056 field_types: app_field_types,
1057 column_types: app_column_types,
1058 icons: app_icons,
1059 screens: app_screens,
1060 public_routes: app_public,
1061 plugin_defined,
1062 } = contributions;
1063 let admin_path = normalize_path(&config.path);
1064
1065 let mut resources = builtin_resources();
1066 let mut app_resources = app_resources;
1067 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1071 prefix_resource(&admin_path, resource);
1072 }
1073
1074 let mut settings = builtin_settings();
1075 settings.extend(app_settings);
1076 let mut app_listeners = app_listeners;
1079 app_listeners.insert(
1080 0,
1081 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1082 );
1083
1084 let mut permissions = builtin_permissions();
1085 permissions.extend(app_permissions);
1086 let permission_codes: std::collections::HashSet<String> =
1088 permissions.iter().map(|p| p.code.clone()).collect();
1089
1090 let mut nav = vec![NavLink {
1094 label: "Dashboard".into(),
1095 path: admin_path.clone(),
1096 icon: Some("layout-dashboard"),
1097 }];
1098 for resource in &app_resources {
1099 nav.push(NavLink {
1100 label: resource.nav_label.clone(),
1101 path: resource.base_path.clone(),
1102 icon: None,
1103 });
1104 }
1105 for reg in &app_screens {
1108 if let Some(label) = ®.nav_label {
1109 nav.push(NavLink {
1110 label: label.clone(),
1111 path: format!("{admin_path}{}", reg.base_path),
1112 icon: None,
1113 });
1114 }
1115 }
1116 nav.push(NavLink {
1117 label: "Settings".into(),
1118 path: format!("{admin_path}/settings"),
1119 icon: Some("settings"),
1120 });
1121 resources.extend(app_resources);
1122
1123 let app_name = if config.app_name.trim().is_empty() {
1124 "Laterite".to_string()
1125 } else {
1126 config.app_name.clone()
1127 };
1128
1129 let mut pickers = picker::PickerRegistry::new();
1132 for reg in app_picker_sources {
1133 let name = reg.name.clone();
1134 if !field::is_name(&name, true) {
1135 panic!("invalid picker source name `{name}`");
1136 }
1137 if pickers.insert(name.clone(), reg).is_some() {
1138 panic!("duplicate picker source `{name}`");
1139 }
1140 }
1141
1142 let pickers = Arc::new(pickers);
1146 let mut field_types = field::builtin_registry();
1147 field_types.insert(
1148 "reference".to_string(),
1149 Arc::new(field::RefPickerField::new(pickers.clone())),
1150 );
1151
1152 let mut persisters = persist::PersisterRegistry::new();
1155 for reg in app_persisters {
1156 let name = reg.name.clone();
1157 if !field::is_name(&name, true) {
1158 panic!("invalid persister name `{name}`");
1159 }
1160 if persisters.insert(name.clone(), reg.persister).is_some() {
1161 panic!("duplicate persister `{name}`");
1162 }
1163 }
1164
1165 for reg in app_field_types {
1171 let name = reg.name().to_string();
1172 if !field::is_name(&name, true) {
1173 panic!("invalid field type name `{name}`");
1174 }
1175 if field_types.insert(name.clone(), reg.field_type).is_some() {
1176 panic!("duplicate field type `{name}`");
1177 }
1178 }
1179
1180 let mut icon_set = laterite_core::icons::IconSet::new();
1184 for (owner, reg) in app_icons {
1185 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1186 panic!("module `{owner}` contributed an invalid icon: {e}");
1187 }
1188 }
1189
1190 let mut assets = builtin_assets();
1193 assets.insert(
1194 ICON_SPRITE,
1195 AdminAsset {
1196 mime: "image/svg+xml",
1197 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1198 },
1199 );
1200
1201 for item in &settings {
1205 icons::require(
1206 &icon_set,
1207 &format!("settings item `{}`", item.code),
1208 item.icon.as_deref(),
1209 );
1210 }
1211 for link in &nav {
1212 icons::require(&icon_set, &format!("menu entry `{}`", link.path), link.icon);
1213 }
1214
1215 let mut column_types = list::builtin_column_registry();
1219 for reg in app_column_types {
1220 let name = reg.name().to_string();
1221 if !field::is_name(&name, true) {
1222 panic!("invalid column type name `{name}`");
1223 }
1224 if column_types.insert(name.clone(), reg.column_type).is_some() {
1225 panic!("duplicate column type `{name}`");
1226 }
1227 }
1228
1229 let settings = Arc::new(settings);
1230 let asset_urls_arc = Arc::new(asset_urls(&assets));
1232 let sprite_url: Arc<str> = Arc::from(
1233 asset_urls_arc
1234 .get(ICON_SPRITE)
1235 .map(|p| format!("{admin_path}/assets/{p}"))
1236 .unwrap_or_default()
1237 .as_str(),
1238 );
1239 let icons_arc = Arc::new(icon_set);
1240 let state = AdminState {
1241 auth,
1242 db,
1243 nav: Arc::new(nav),
1244 settings: settings.clone(),
1245 permissions: Arc::new(permissions),
1246 admin_path: Arc::from(admin_path.as_str()),
1247 secure_cookie: config.secure_cookie,
1248 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1249 icons: icons_arc.clone(),
1250 sprite_url: sprite_url.clone(),
1251 origin: Arc::from(config.origin.trim_end_matches('/')),
1252 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1253 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1254 catalogs,
1255 app_name,
1256 brand_cache: Arc::new(RwLock::new(None)),
1257 field_types: Arc::new(field_types),
1258 column_types: Arc::new(column_types),
1259 plugin_defined,
1260 assets: Arc::new(assets),
1261 asset_urls: asset_urls_arc.clone(),
1262 pickers,
1263 overrides: Arc::new(field::NoOverrides),
1264 };
1265
1266 let mut protected = Router::new().route(&admin_path, get(dashboard));
1267 for resource in &resources {
1268 protected = protected.merge(mount_resource(
1269 resource,
1270 &state.field_types,
1271 &state.column_types,
1272 &permission_codes,
1273 &persisters,
1274 &app_listeners,
1275 ));
1276 }
1277 for reg in &app_screens {
1281 let base = format!("{admin_path}{}", reg.base_path);
1282 let ctx = routes::RouteCtx::new(
1283 state.db.clone(),
1284 &base,
1285 &admin_path,
1286 &state.origin,
1287 state.plugin_defined.clone(),
1288 );
1289 protected = protected.merge(guard_with_permission(
1290 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1291 ®.permission,
1292 ));
1293 }
1294
1295 protected = protected.merge(guard_with_permission(
1298 Router::new()
1299 .route(
1300 &format!("{admin_path}/roles/new"),
1301 get(roles::new_form).post(roles::create),
1302 )
1303 .route(
1304 &format!("{admin_path}/roles/{{id}}/edit"),
1305 get(roles::edit_form).post(roles::update),
1306 ),
1307 "backend.manage_roles",
1308 ));
1309 protected = protected.merge(guard_with_permission(
1312 Router::new()
1313 .route(
1314 &format!("{admin_path}/users/{{id}}/edit"),
1315 get(users::edit_form).post(users::update),
1316 )
1317 .route(
1318 &format!("{admin_path}/users/{{id}}/active"),
1319 post(users::set_active),
1320 ),
1321 "backend.manage_users",
1322 ));
1323 protected = protected.merge(guard_with_permission(
1326 Router::new()
1327 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1328 .route(
1329 &format!("{admin_path}/plugins/toggle"),
1330 post(plugins::toggle),
1331 ),
1332 plugins::MANAGE_PERMISSION,
1333 ));
1334 protected = protected
1335 .route(&format!("{admin_path}/settings"), get(settings_index))
1336 .route(
1337 &format!("{admin_path}/preferences"),
1338 get(preferences_form).post(preferences_update),
1339 )
1340 .route(
1341 &format!("{admin_path}/preferences/sessions/revoke"),
1342 post(session_revoke),
1343 )
1344 .route(
1345 &format!("{admin_path}/preferences/sessions/revoke-others"),
1346 post(session_revoke_others),
1347 )
1348 .route(
1351 &format!("{admin_path}/pickers/{{source}}/search"),
1352 any(picker::search),
1353 )
1354 .route(
1355 &format!("{admin_path}/pickers/{{source}}/resolve"),
1356 any(picker::resolve),
1357 )
1358 .route(&format!("{admin_path}/logout"), post(logout));
1359
1360 for item in settings.iter().filter(|i| i.link.is_none()) {
1364 let path = format!("{admin_path}{}", item.route());
1365 let code = item.code.clone();
1366 let edit_code = code.clone();
1367 protected = protected.route(
1368 &path,
1369 get(
1370 move |State(state): State<AdminState>,
1371 Extension(shell): Extension<Shell>,
1372 Extension(user): Extension<AuthenticatedUser>| {
1373 let code = edit_code.clone();
1374 async move { settings_edit(state, shell, user, code).await }
1375 },
1376 )
1377 .post(
1378 move |State(state): State<AdminState>,
1379 Extension(shell): Extension<Shell>,
1380 Extension(user): Extension<AuthenticatedUser>,
1381 Extension(session): Extension<session::SessionHandle>,
1382 Form(data): Form<HashMap<String, String>>| {
1383 let code = code.clone();
1384 async move { settings_update(state, shell, user, session, code, data).await }
1385 },
1386 ),
1387 );
1388 }
1389
1390 protected
1391 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1392 .route(
1396 &format!("{admin_path}/login"),
1397 get(login_form).post(login_submit),
1398 )
1399 .route(
1400 &format!("{admin_path}/setup"),
1401 get(setup_form).post(setup_submit),
1402 )
1403 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1404 .merge(mount_public(
1408 &app_public,
1409 &state.db,
1410 &admin_path,
1411 &state.origin,
1412 &state.plugin_defined,
1413 ))
1414 .fallback(not_found_fallback)
1416 .layer(middleware::from_fn_with_state(
1419 state.clone(),
1420 enforce_origin,
1421 ))
1422 .layer(middleware::from_fn_with_state(
1425 state.clone(),
1426 capture_client,
1427 ))
1428 .layer(
1433 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1434 header::CACHE_CONTROL,
1435 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1436 ),
1437 )
1438 .layer(
1442 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1443 header::VARY,
1444 axum::http::HeaderValue::from_static("HX-Request"),
1445 ),
1446 )
1447 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1448 handle_panic,
1449 ))
1450 .with_state(state)
1451}
1452
1453async fn not_found_fallback() -> AdminError {
1455 AdminError::NotFound
1456}
1457
1458fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1460 let cause = err
1461 .downcast_ref::<&str>()
1462 .map(|s| s.to_string())
1463 .or_else(|| err.downcast_ref::<String>().cloned())
1464 .unwrap_or_else(|| "unknown panic".to_string());
1465 tracing::error!(panic = cause, "admin handler panicked");
1466 error::masked_500()
1467}
1468
1469pub fn normalize_path(path: &str) -> String {
1474 let trimmed = path.trim().trim_matches('/');
1475 if trimmed.is_empty() {
1476 "/admin".to_string()
1477 } else {
1478 format!("/{trimmed}")
1479 }
1480}
1481
1482fn mount_public(
1486 regs: &[routes::PublicRouteReg],
1487 db: &Db,
1488 admin_path: &str,
1489 origin: &str,
1490 plugin_defined: &Arc<laterite_core::Registry>,
1491) -> Router<AdminState> {
1492 let mut router = Router::new();
1493 for reg in regs {
1494 let ctx = routes::RouteCtx::new(
1495 db.clone(),
1496 ®.path,
1497 admin_path,
1498 origin,
1499 plugin_defined.clone(),
1500 );
1501 router = router.nest_service(
1506 ®.path,
1507 reg.route.mount(&ctx).fallback(not_found_fallback),
1508 );
1509 }
1510 router
1511}
1512
1513pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1516 let old = resource.base_path.clone();
1517 resource.base_path = base.to_string();
1518 if let Some(edit_base) = &mut resource.list.edit_base {
1519 if edit_base == &old {
1520 *edit_base = base.to_string();
1521 }
1522 }
1523 if let Some(form) = &mut resource.form {
1524 if form.base_path == old {
1525 form.base_path = base.to_string();
1526 }
1527 }
1528}
1529
1530fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1531 resource.base_path = format!("{admin_path}{}", resource.base_path);
1532 if let Some(edit_base) = &mut resource.list.edit_base {
1533 *edit_base = format!("{admin_path}{edit_base}");
1534 }
1535 if let Some(form) = &mut resource.form {
1536 form.base_path = format!("{admin_path}{}", form.base_path);
1537 }
1538}
1539
1540pub(crate) struct AdminAsset {
1542 pub mime: &'static str,
1543 pub bytes: std::borrow::Cow<'static, [u8]>,
1547}
1548
1549pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1552
1553pub(crate) type AssetUrls = HashMap<&'static str, String>;
1557
1558pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
1560 registry
1561 .iter()
1562 .map(|(&key, asset)| {
1563 (
1564 key,
1565 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
1566 )
1567 })
1568 .collect()
1569}
1570
1571pub(crate) fn page_assets(
1578 keys: &[&str],
1579 base: &str,
1580 registry: &AssetRegistry,
1581 urls: &AssetUrls,
1582) -> Vec<PageAsset> {
1583 let mut seen = std::collections::HashSet::new();
1584 let mut out = Vec::new();
1585 for &key in keys {
1586 if !seen.insert(key) {
1587 continue;
1588 }
1589 match (registry.get(key), urls.get(key)) {
1590 (Some(asset), Some(path)) => out.push(PageAsset {
1591 url: format!("{base}/assets/{path}"),
1592 css: asset.mime.contains("css"),
1593 }),
1594 _ => debug_assert!(false, "asset key `{key}` is not registered"),
1595 }
1596 }
1597 out
1598}
1599
1600pub(crate) fn builtin_assets() -> AssetRegistry {
1609 let font = |bytes: &'static [u8]| AdminAsset {
1610 mime: "font/woff2",
1611 bytes: std::borrow::Cow::Borrowed(bytes),
1612 };
1613 HashMap::from([
1614 (
1615 "laterite.css",
1616 AdminAsset {
1617 mime: "text/css; charset=utf-8",
1618 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
1619 },
1620 ),
1621 (
1622 "laterite.js",
1623 AdminAsset {
1624 mime: "text/javascript; charset=utf-8",
1625 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
1626 },
1627 ),
1628 (
1629 "vendor/htmx.min.js",
1630 AdminAsset {
1631 mime: "text/javascript; charset=utf-8",
1632 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
1633 },
1634 ),
1635 (
1636 "fields/ref-picker.js",
1637 AdminAsset {
1638 mime: "text/javascript; charset=utf-8",
1639 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
1640 },
1641 ),
1642 (
1643 "fields/ref-picker.css",
1644 AdminAsset {
1645 mime: "text/css; charset=utf-8",
1646 bytes: std::borrow::Cow::Borrowed(include_bytes!(
1647 "../assets/fields/ref-picker.css"
1648 )),
1649 },
1650 ),
1651 (
1652 "mark.svg",
1653 AdminAsset {
1654 mime: "image/svg+xml",
1655 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
1656 },
1657 ),
1658 (
1659 "mark.png",
1660 AdminAsset {
1661 mime: "image/png",
1662 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
1663 },
1664 ),
1665 (
1666 "fonts/space-grotesk-500.woff2",
1667 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1668 ),
1669 (
1670 "fonts/space-grotesk-600.woff2",
1671 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1672 ),
1673 (
1674 "fonts/space-grotesk-700.woff2",
1675 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1676 ),
1677 (
1678 "fonts/ibm-plex-sans-400.woff2",
1679 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1680 ),
1681 (
1682 "fonts/ibm-plex-sans-600.woff2",
1683 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1684 ),
1685 (
1686 "fonts/ibm-plex-mono-400.woff2",
1687 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1688 ),
1689 (
1690 "fonts/ibm-plex-mono-600.woff2",
1691 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1692 ),
1693 ])
1694}
1695
1696async fn serve_asset(
1698 State(state): State<AdminState>,
1699 headers: HeaderMap,
1700 Path(path): Path<String>,
1701) -> Response {
1702 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
1706 let Some(asset) = state.assets.get(key.as_str()) else {
1707 return not_found();
1708 };
1709 let policy = if fingerprinted {
1710 http_cache::IMMUTABLE
1711 } else {
1712 http_cache::REVALIDATE
1713 };
1714 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
1715}
1716
1717fn mount_resource(
1722 resource: &Resource,
1723 field_types: &field::FieldRegistry,
1724 column_types: &list::ColumnRegistry,
1725 permission_codes: &std::collections::HashSet<String>,
1726 persisters: &persist::PersisterRegistry,
1727 listeners: &[laterite_core::ModelListenerReg],
1728) -> Router<AdminState> {
1729 list::check(&resource.list, column_types, permission_codes)
1733 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
1734 if resource.form.is_some() && resource.permission.is_none() {
1739 panic!(
1740 "admin resource `{}` has a create/edit form but no permission; a write \
1741 resource must declare a permission",
1742 resource.base_path
1743 );
1744 }
1745 let base = resource.base_path.clone();
1746 let list_path = base.clone();
1747 let list_cfg = resource.list.clone();
1748 let mut router = Router::new().route(
1749 &base,
1750 get(
1751 move |State(state): State<AdminState>,
1752 Extension(shell): Extension<Shell>,
1753 Query(params): Query<list::ListParams>,
1754 Query(raw): Query<std::collections::HashMap<String, String>>,
1755 Extension(user): Extension<AuthenticatedUser>,
1756 headers: axum::http::HeaderMap| {
1757 let cfg = list_cfg.clone();
1758 let path = list_path.clone();
1759 async move {
1760 list::handle(&state, &cfg, &path, params, &raw, &user, shell, &headers).await
1761 }
1762 },
1763 ),
1764 );
1765
1766 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
1769 if resource.list.exportable {
1770 router = router.route(
1771 &format!("{base}/export"),
1772 get(
1773 move |state: State<AdminState>,
1774 Query(raw): Query<std::collections::HashMap<String, String>>,
1775 Extension(user): Extension<AuthenticatedUser>,
1776 Extension(shell): Extension<Shell>,
1777 Extension(session): Extension<session::SessionHandle>| {
1778 let cfg = export_cfg.clone();
1779 let path = export_path.clone();
1780 async move {
1781 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
1782 }
1783 },
1784 ),
1785 );
1786 }
1787
1788 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
1789 router = router.route(
1790 &format!("{base}/columns"),
1791 post(
1792 move |State(state): State<AdminState>,
1793 Extension(user): Extension<AuthenticatedUser>,
1794 Extension(session): Extension<session::SessionHandle>,
1795 headers: axum::http::HeaderMap,
1796 Form(pairs): Form<Vec<(String, String)>>| {
1797 let cfg = columns_cfg.clone();
1798 let path = columns_path.clone();
1799 async move {
1800 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
1801 }
1802 },
1803 ),
1804 );
1805
1806 if resource.list.deletable {
1807 let persister: Arc<dyn persist::Persister> =
1811 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
1812 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
1813 panic!(
1814 "admin resource `{}`: unregistered persister `{key}`",
1815 resource.base_path
1816 )
1817 }),
1818 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
1819 };
1820 let ctx = Arc::new(bulk::BulkContext {
1821 base_path: resource.base_path.clone(),
1822 entity: resource.list.entity.clone(),
1823 persister,
1824 listeners: listeners
1825 .iter()
1826 .filter(|reg| reg.matches(&resource.list.entity))
1827 .map(|reg| reg.listener.clone())
1828 .collect(),
1829 });
1830 router =
1831 router.route(
1832 &format!("{base}/delete"),
1833 post(
1834 move |state: State<AdminState>,
1835 user: Extension<AuthenticatedUser>,
1836 session: Extension<session::SessionHandle>,
1837 headers: axum::http::HeaderMap,
1838 form: Form<Vec<(String, String)>>| {
1839 let ctx = ctx.clone();
1840 async move {
1841 bulk::delete_handler(state, user, session, ctx, headers, form).await
1842 }
1843 },
1844 ),
1845 );
1846 }
1847
1848 if let Some(form_cfg) = resource.form.clone() {
1849 let prepared = Arc::new(
1852 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
1853 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1854 );
1855 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1856 router = router.route(
1857 &format!("{base}/new"),
1858 get(
1859 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1860 let pf = new_pf.clone();
1861 async move { form::new_form(&state, &pf, shell) }
1862 },
1863 )
1864 .post(
1865 move |State(state): State<AdminState>,
1866 Extension(shell): Extension<Shell>,
1867 Extension(user): Extension<AuthenticatedUser>,
1868 Extension(session): Extension<session::SessionHandle>,
1869 headers: axum::http::HeaderMap,
1870 Form(data): Form<HashMap<String, String>>| {
1871 let pf = create_pf.clone();
1872 async move {
1873 form::create(&state, &pf, data, shell, &user, &session, &headers).await
1874 }
1875 },
1876 ),
1877 );
1878
1879 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1880 router = router.route(
1881 &format!("{base}/{{id}}/edit"),
1882 get(
1883 move |State(state): State<AdminState>,
1884 Extension(shell): Extension<Shell>,
1885 Path(id): Path<String>| {
1886 let pf = edit_pf.clone();
1887 async move { form::edit_form(&state, &pf, id, shell).await }
1888 },
1889 )
1890 .post(
1891 move |State(state): State<AdminState>,
1892 Extension(shell): Extension<Shell>,
1893 Extension(user): Extension<AuthenticatedUser>,
1894 Extension(session): Extension<session::SessionHandle>,
1895 Path(id): Path<String>,
1896 headers: axum::http::HeaderMap,
1897 Form(data): Form<HashMap<String, String>>| {
1898 let pf = update_pf.clone();
1899 async move {
1900 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
1901 }
1902 },
1903 ),
1904 );
1905 }
1906
1907 if let Some(permission) = &resource.permission {
1909 router = guard_with_permission(router, permission);
1910 }
1911 router
1912}
1913
1914fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
1919 let needed: Arc<str> = Arc::from(permission);
1920 router.route_layer(middleware::from_fn(
1921 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
1922 let needed = needed.clone();
1923 async move {
1924 if user.allows(&needed) {
1925 next.run(req).await
1926 } else {
1927 forbidden()
1928 }
1929 }
1930 },
1931 ))
1932}
1933
1934async fn require_auth(
1940 State(state): State<AdminState>,
1941 jar: CookieJar,
1942 request: Request,
1943 next: Next,
1944) -> Response {
1945 let login = format!("{}/login", state.admin_path);
1946 let client_ctx = request
1947 .extensions()
1948 .get::<RequestContext>()
1949 .cloned()
1950 .unwrap_or_default();
1951 let live = match jar.get(SESSION_COOKIE) {
1955 Some(cookie) => {
1956 let token = cookie.value().to_string();
1957 state
1958 .auth
1959 .resolve_session(&token)
1960 .await
1961 .ok()
1962 .map(|resolved| (token, resolved))
1963 }
1964 None => None,
1965 };
1966 let (token, resolved, recalled) = match live {
1967 Some((token, resolved)) => (token, resolved, None),
1968 None => match recall(&state, &jar, &client_ctx).await {
1969 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
1970 None => {
1973 return (
1974 jar.remove(remember_removal(&state.admin_path)),
1975 Redirect::to(&login),
1976 )
1977 .into_response()
1978 }
1979 },
1980 };
1981 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
1982
1983 let mut request = request;
1988 let safe = session::is_safe_method(request.method());
1989 let mut deferred: Option<session::CsrfPending> = None;
1991 if !safe {
1992 let (parts, body) = request.into_parts();
1993 let (body, submitted) = if session::is_multipart(&parts.headers) {
2000 let token = session::header_token(&parts.headers)
2001 .or_else(|| session::query_token(parts.uri.query()));
2002 if token.is_none() {
2007 deferred = Some(session::CsrfPending::default());
2008 }
2009 (body, token)
2010 } else {
2011 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2012 .await
2013 .unwrap_or_default();
2014 let token = session::submitted_token(&parts.headers, &bytes);
2015 (Body::from(bytes), token)
2016 };
2017 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2018 {
2019 tracing::warn!(
2020 user_id = resolved.identity.user.id,
2021 "admin CSRF check failed"
2022 );
2023 return error::csrf_rejected();
2024 }
2025 request = Request::from_parts(parts, body);
2026 }
2027
2028 let flash = if safe {
2031 handle.take_flash()
2032 } else {
2033 Vec::new()
2034 };
2035 let user = resolved.identity;
2036 let acting_user_id = user.user.id;
2038 let path = request.uri().path().to_string();
2039 let accept_language = request
2043 .headers()
2044 .get("accept-language")
2045 .and_then(|v| v.to_str().ok());
2046 let serveable = state.catalogs.locales();
2047 let chain = resolve_locale_chain(
2048 user.user.locale.as_deref(),
2049 accept_language,
2050 &state.default_locale,
2051 &serveable,
2052 );
2053 let i18n = Translator::with_chain(chain, state.catalogs.clone());
2054 let translator = i18n.clone();
2057 let (sidebar, active_nav) = resolve_nav_context(
2058 &state.nav,
2059 &state.settings,
2060 &state.admin_path,
2061 &user.permissions,
2062 &path,
2063 &i18n,
2064 icons::Icons::new(&state.icons, &state.sprite_url),
2065 );
2066 let brand = state.brand().await;
2067 let shell = Shell::new(
2068 &state.admin_path,
2069 brand,
2070 &state.nav,
2071 &user,
2072 state.timezone,
2073 sidebar,
2074 active_nav.as_deref(),
2075 handle.csrf_token(),
2076 flash,
2077 i18n,
2078 state.asset_urls.clone(),
2079 state.origin.to_string(),
2080 state.icons.clone(),
2081 state.sprite_url.clone(),
2082 );
2083 request.extensions_mut().insert(user);
2084 request.extensions_mut().insert(shell);
2085 request.extensions_mut().insert(handle.clone());
2086 if let Some(pending) = &deferred {
2088 request.extensions_mut().insert(pending.clone());
2089 }
2090 let mut response = next.run(request).await;
2091
2092 if let Some(pending) = &deferred {
2097 if !pending.was_satisfied() {
2098 tracing::error!(
2099 user_id = acting_user_id,
2100 "a multipart route returned without verifying its request token"
2101 );
2102 return error::csrf_rejected();
2103 }
2104 }
2105
2106 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2110 response = error::localized_error(meta.kind, &translator);
2111 }
2112
2113 if let Some(blob) = handle.dirty_blob() {
2116 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2117 tracing::error!(error = %e, "persisting admin session failed");
2118 }
2119 }
2120 if let Some(credential) = recalled {
2123 let jar = jar
2124 .add(session_cookie(
2125 token,
2126 &state.admin_path,
2127 state.secure_cookie,
2128 ))
2129 .add(remember_cookie(
2130 credential,
2131 &state.admin_path,
2132 state.secure_cookie,
2133 ));
2134 return (jar, response).into_response();
2135 }
2136 response
2137}
2138
2139async fn recall(
2143 state: &AdminState,
2144 jar: &CookieJar,
2145 ctx: &RequestContext,
2146) -> Option<(
2147 String,
2148 laterite_auth::ResolvedSession,
2149 laterite_auth::RememberCredential,
2150)> {
2151 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
2152 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
2153 let token = recalled.session.token.clone();
2154 let resolved = state.auth.resolve_session(&token).await.ok()?;
2155 Some((token, resolved, recalled.remember))
2156}
2157
2158const MAX_FORM_BYTES: usize = 1024 * 1024;
2161
2162async fn capture_client(
2170 State(state): State<AdminState>,
2171 mut request: Request,
2172 next: Next,
2173) -> Response {
2174 let peer = request
2175 .extensions()
2176 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
2177 .map(|info| info.0);
2178 let headers = request.headers();
2179 let ctx = RequestContext {
2180 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
2181 user_agent: clientip::user_agent(headers),
2182 };
2183 request.extensions_mut().insert(ctx);
2184 next.run(request).await
2185}
2186
2187async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
2188 if !session::is_safe_method(request.method())
2189 && !session::origin_ok(request.headers(), &state.origin)
2190 {
2191 tracing::warn!(
2192 path = request.uri().path(),
2193 "admin CSRF origin check failed"
2194 );
2195 return error::csrf_rejected();
2196 }
2197 next.run(request).await
2198}
2199
2200async fn login_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2201 match state.auth.has_any_operator().await {
2203 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
2204 Ok(true) => render(LoginTemplate {
2205 base: state.admin_path.to_string(),
2206 asset_urls: state.asset_urls.clone(),
2207 brand: state.brand().await,
2208 error: None,
2209 i18n: pre_auth_translator(&state, &headers),
2210 }),
2211 Err(_) => render_error(),
2212 }
2213}
2214
2215#[derive(Deserialize)]
2216struct LoginForm {
2217 username: String,
2218 password: String,
2219 #[serde(default)]
2221 remember: Option<String>,
2222}
2223
2224async fn login_submit(
2225 State(state): State<AdminState>,
2226 jar: CookieJar,
2227 headers: HeaderMap,
2228 Extension(ctx): Extension<RequestContext>,
2229 Form(form): Form<LoginForm>,
2230) -> Response {
2231 match state
2232 .auth
2233 .authenticate(&form.username, &form.password, &ctx)
2234 .await
2235 {
2236 Ok(session) => {
2237 let wants_remember = form.remember.is_some();
2238 let user_id = session.user_id;
2239 let mut jar = jar.add(session_cookie(
2240 session.token,
2241 &state.admin_path,
2242 state.secure_cookie,
2243 ));
2244 if wants_remember {
2245 match state.auth.issue_remember(user_id).await {
2246 Ok(credential) => {
2247 jar = jar.add(remember_cookie(
2248 credential,
2249 &state.admin_path,
2250 state.secure_cookie,
2251 ));
2252 }
2253 Err(e) => {
2255 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
2256 }
2257 }
2258 }
2259 (jar, Redirect::to(&state.admin_path)).into_response()
2260 }
2261 Err(_) => {
2262 let i18n = pre_auth_translator(&state, &headers);
2263 render(LoginTemplate {
2264 base: state.admin_path.to_string(),
2265 asset_urls: state.asset_urls.clone(),
2266 brand: state.brand().await,
2267 error: Some(i18n.t(&t!("Invalid username or password."))),
2268 i18n,
2269 })
2270 }
2271 }
2272}
2273
2274fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
2283 Cookie::build((SESSION_COOKIE, token))
2284 .path(admin_path.to_string())
2285 .http_only(true)
2286 .secure(secure)
2287 .same_site(SameSite::Lax)
2288 .build()
2289}
2290
2291fn remember_cookie(
2294 credential: laterite_auth::RememberCredential,
2295 admin_path: &str,
2296 secure: bool,
2297) -> Cookie<'static> {
2298 let seconds = (credential.expires_at - chrono::Utc::now())
2299 .num_seconds()
2300 .max(0);
2301 Cookie::build((REMEMBER_COOKIE, credential.cookie))
2302 .path(admin_path.to_string())
2303 .http_only(true)
2304 .secure(secure)
2305 .same_site(SameSite::Lax)
2306 .max_age(cookie::time::Duration::seconds(seconds))
2307 .build()
2308}
2309
2310fn remember_removal(admin_path: &str) -> Cookie<'static> {
2312 Cookie::build((REMEMBER_COOKIE, ""))
2313 .path(admin_path.to_string())
2314 .build()
2315}
2316
2317#[derive(Deserialize)]
2318struct SetupForm {
2319 username: String,
2320 first_name: String,
2321 last_name: String,
2322 email: String,
2323 password: String,
2324 timezone: String,
2325}
2326
2327async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2330 match state.auth.has_any_operator().await {
2331 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2332 Ok(false) => render(setup_view(
2333 &state.admin_path,
2334 state.brand().await,
2335 state.timezone,
2336 None,
2337 pre_auth_translator(&state, &headers),
2338 state.asset_urls.clone(),
2339 )),
2340 Err(_) => render_error(),
2341 }
2342}
2343
2344async fn setup_submit(
2345 State(state): State<AdminState>,
2346 jar: CookieJar,
2347 headers: HeaderMap,
2348 Form(form): Form<SetupForm>,
2349) -> Response {
2350 match state.auth.has_any_operator().await {
2352 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2353 Ok(false) => {}
2354 Err(_) => return render_error(),
2355 }
2356
2357 let username = form.username.trim();
2358 let email = form.email.trim();
2359 let first_name = form.first_name.trim();
2360 let last_name = form.last_name.trim();
2361 let tz = form.timezone.trim();
2362 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
2363 {
2364 return render(setup_view(
2365 &state.admin_path,
2366 state.brand().await,
2367 state.timezone,
2368 Some(t!(
2369 "Username, first name, email, and password are all required."
2370 )),
2371 pre_auth_translator(&state, &headers),
2372 state.asset_urls.clone(),
2373 ));
2374 }
2375 if tz.parse::<Tz>().is_err() {
2377 return render(setup_view(
2378 &state.admin_path,
2379 state.brand().await,
2380 state.timezone,
2381 Some(t!("That is not a recognised timezone.")),
2382 pre_auth_translator(&state, &headers),
2383 state.asset_urls.clone(),
2384 ));
2385 }
2386
2387 let new = NewOperator {
2388 username,
2389 email,
2390 first_name,
2391 last_name: (!last_name.is_empty()).then_some(last_name),
2392 password: &form.password,
2393 timezone: Some(tz),
2394 };
2395 if state.auth.create_superuser(new).await.is_err() {
2396 return render(setup_view(
2397 &state.admin_path,
2398 state.brand().await,
2399 state.timezone,
2400 Some(t!(
2401 "Could not create the account. The username or email may already be taken."
2402 )),
2403 pre_auth_translator(&state, &headers),
2404 state.asset_urls.clone(),
2405 ));
2406 }
2407
2408 match state
2410 .auth
2411 .authenticate(username, &form.password, &RequestContext::default())
2412 .await
2413 {
2414 Ok(session) => {
2415 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
2416 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
2417 }
2418 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2419 }
2420}
2421
2422fn setup_view(
2425 admin_path: &str,
2426 brand: String,
2427 default_tz: Tz,
2428 error: Option<Text>,
2429 i18n: Translator,
2430 asset_urls: Arc<AssetUrls>,
2431) -> SetupTemplate {
2432 let default_name = default_tz.name();
2433 let zones = TZ_VARIANTS
2434 .iter()
2435 .filter(|tz| zone_offered(tz.name()))
2436 .map(|tz| TzOption {
2437 name: tz.name().to_string(),
2438 selected: tz.name() == default_name,
2439 })
2440 .collect();
2441 SetupTemplate {
2442 base: admin_path.to_string(),
2443 asset_urls,
2444 brand,
2445 zones,
2446 error: error.map(|e| i18n.t(&e)),
2447 i18n,
2448 }
2449}
2450
2451async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
2452 if let Some(cookie) = jar.get(SESSION_COOKIE) {
2453 let _ = state.auth.logout(cookie.value()).await;
2454 }
2455 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2458 let _ = state.auth.revoke_remember(cookie.value()).await;
2459 }
2460 let removal = Cookie::build((SESSION_COOKIE, ""))
2461 .path(state.admin_path.to_string())
2462 .build();
2463 (
2464 jar.remove(removal)
2465 .remove(remember_removal(&state.admin_path)),
2466 Redirect::to(&format!("{}/login", state.admin_path)),
2467 )
2468 .into_response()
2469}
2470
2471async fn dashboard(
2472 Extension(shell): Extension<Shell>,
2473 Extension(user): Extension<AuthenticatedUser>,
2474) -> Response {
2475 render(DashboardTemplate {
2476 username: user.user.username,
2477 shell,
2478 })
2479}
2480
2481fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
2484 match &item.permission {
2485 None => true,
2486 Some(p) => perms.allows(p),
2487 }
2488}
2489
2490fn visible_settings(
2494 items: &[settings::SettingsItem],
2495 perms: &PermissionSet,
2496) -> Vec<settings::SettingsItem> {
2497 items
2498 .iter()
2499 .filter(|item| operator_can_see(item, perms))
2500 .cloned()
2501 .collect()
2502}
2503
2504async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
2505 settings::index(shell)
2506}
2507
2508async fn settings_edit(
2509 state: AdminState,
2510 shell: Shell,
2511 user: AuthenticatedUser,
2512 code: String,
2513) -> Response {
2514 let items = visible_settings(&state.settings, &user.permissions);
2517 match items
2518 .iter()
2519 .find(|item| item.code == code && item.link.is_none())
2520 {
2521 Some(item) => settings::edit_form(&state, item, shell).await,
2522 None => not_found(),
2523 }
2524}
2525
2526async fn settings_update(
2527 state: AdminState,
2528 shell: Shell,
2529 user: AuthenticatedUser,
2530 session: session::SessionHandle,
2531 code: String,
2532 data: HashMap<String, String>,
2533) -> Response {
2534 let items = visible_settings(&state.settings, &user.permissions);
2535 match items
2536 .iter()
2537 .find(|item| item.code == code && item.link.is_none())
2538 {
2539 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
2540 None => not_found(),
2541 }
2542}
2543
2544async fn preferences_form(
2546 State(state): State<AdminState>,
2547 Extension(shell): Extension<Shell>,
2548 Extension(user): Extension<AuthenticatedUser>,
2549 jar: CookieJar,
2550) -> Response {
2551 let sessions = session_rows(&state, &user, &shell, &jar).await;
2552 render(preferences_view(
2553 &shell,
2554 &user,
2555 state.timezone,
2556 &state.default_locale,
2557 &offered_locales(&state.catalogs),
2558 sessions,
2559 None,
2560 ))
2561}
2562
2563#[derive(Deserialize)]
2564struct RevokeSessionForm {
2565 id: String,
2566}
2567
2568async fn session_revoke(
2570 State(state): State<AdminState>,
2571 Extension(user): Extension<AuthenticatedUser>,
2572 Extension(session): Extension<session::SessionHandle>,
2573 Form(form): Form<RevokeSessionForm>,
2574) -> Response {
2575 match state.auth.revoke_session(user.user.id, &form.id).await {
2576 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
2577 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
2578 Err(e) => {
2579 tracing::error!(error = %e, "revoking a session failed");
2580 session.push_flash(
2581 FlashLevel::Error,
2582 t!("That session could not be signed out."),
2583 );
2584 }
2585 }
2586 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2587}
2588
2589async fn session_revoke_others(
2591 State(state): State<AdminState>,
2592 Extension(user): Extension<AuthenticatedUser>,
2593 Extension(session): Extension<session::SessionHandle>,
2594 jar: CookieJar,
2595) -> Response {
2596 let token = jar
2597 .get(SESSION_COOKIE)
2598 .map(|c| c.value().to_string())
2599 .unwrap_or_default();
2600 match state.auth.sign_out_everywhere(user.user.id, &token).await {
2601 Ok(_) => session.push_flash(
2602 FlashLevel::Success,
2603 t!("Every other session was signed out."),
2604 ),
2605 Err(e) => {
2606 tracing::error!(error = %e, "signing out other sessions failed");
2607 session.push_flash(
2608 FlashLevel::Error,
2609 t!("Those sessions could not be signed out."),
2610 );
2611 }
2612 }
2613 (
2616 jar.remove(remember_removal(&state.admin_path)),
2617 Redirect::to(&format!("{}/preferences", state.admin_path)),
2618 )
2619 .into_response()
2620}
2621
2622#[derive(Deserialize)]
2623struct PreferencesForm {
2624 timezone: String,
2625 #[serde(default)]
2627 locale: String,
2628}
2629
2630async fn preferences_update(
2631 State(state): State<AdminState>,
2632 Extension(shell): Extension<Shell>,
2633 Extension(user): Extension<AuthenticatedUser>,
2634 Extension(session): Extension<session::SessionHandle>,
2635 jar: CookieJar,
2636 Form(form): Form<PreferencesForm>,
2637) -> Response {
2638 let offered = offered_locales(&state.catalogs);
2639 let tz = form.timezone.trim();
2641 let tz_stored = if tz.is_empty() {
2642 None
2643 } else if tz.parse::<Tz>().is_ok() {
2644 Some(tz)
2645 } else {
2646 return render(preferences_view(
2647 &shell,
2648 &user,
2649 state.timezone,
2650 &state.default_locale,
2651 &offered,
2652 session_rows(&state, &user, &shell, &jar).await,
2653 Some(t!("That is not a recognised timezone.")),
2654 ));
2655 };
2656 let loc = form.locale.trim();
2657 let loc_stored = if loc.is_empty() {
2658 None
2659 } else if offered.iter().any(|l| l == loc) {
2660 Some(loc)
2661 } else {
2662 return render(preferences_view(
2663 &shell,
2664 &user,
2665 state.timezone,
2666 &state.default_locale,
2667 &offered,
2668 session_rows(&state, &user, &shell, &jar).await,
2669 Some(t!("That is not a supported language.")),
2670 ));
2671 };
2672 if state
2673 .auth
2674 .set_user_timezone(user.user.id, tz_stored)
2675 .await
2676 .is_err()
2677 {
2678 return render_error();
2679 }
2680 match state.auth.set_user_locale(user.user.id, loc_stored).await {
2681 Ok(()) => {
2682 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
2683 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2684 }
2685 Err(_) => render_error(),
2686 }
2687}
2688
2689fn preferences_view(
2693 shell: &Shell,
2694 user: &AuthenticatedUser,
2695 default_tz: Tz,
2696 default_locale: &str,
2697 offered: &[String],
2698 sessions: Vec<SessionRow>,
2699 error: Option<Text>,
2700) -> PreferencesTemplate {
2701 let current = user.user.timezone.as_deref();
2702 let zones = TZ_VARIANTS
2703 .iter()
2704 .filter(|tz| zone_offered(tz.name()))
2705 .map(|tz| TzOption {
2706 name: tz.name().to_string(),
2707 selected: current == Some(tz.name()),
2708 })
2709 .collect();
2710 let current_locale = user.user.locale.as_deref();
2711 let locales = offered
2712 .iter()
2713 .map(|code| LocaleOption {
2714 name: locale_name(code),
2715 selected: current_locale == Some(code.as_str()),
2716 code: code.clone(),
2717 })
2718 .collect();
2719 PreferencesTemplate {
2720 shell: shell.clone(),
2721 zones,
2722 effective_tz: shell.tz.name().to_string(),
2723 default_tz: default_tz.name().to_string(),
2724 inherits: current.is_none(),
2725 locales,
2726 default_locale: locale_name(default_locale),
2727 inherits_locale: current_locale.is_none(),
2728 sessions,
2729 error: error.map(|e| shell.tt(&e)),
2730 }
2731}
2732
2733async fn session_rows(
2736 state: &AdminState,
2737 user: &AuthenticatedUser,
2738 shell: &Shell,
2739 jar: &CookieJar,
2740) -> Vec<SessionRow> {
2741 let token = jar
2742 .get(SESSION_COOKIE)
2743 .map(|c| c.value().to_string())
2744 .unwrap_or_default();
2745 let locale = list::date_locale(shell.locale());
2746 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
2747 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
2748 };
2749 match state.auth.list_sessions(user.user.id, &token).await {
2750 Ok(sessions) => sessions
2751 .into_iter()
2752 .map(|s| SessionRow {
2753 id: s.id,
2754 started: stamp(s.created_at),
2755 last_seen: stamp(s.last_seen_at),
2756 expires: stamp(s.expires_at),
2757 device: clientip::describe(s.user_agent.as_deref())
2758 .or(s.user_agent)
2759 .unwrap_or_else(|| "-".to_string()),
2760 ip_address: s.ip_address.unwrap_or_default(),
2761 current: s.current,
2762 })
2763 .collect(),
2764 Err(e) => {
2765 tracing::error!(error = %e, "listing sessions failed");
2766 Vec::new()
2767 }
2768 }
2769}
2770
2771fn builtin_resources() -> Vec<Resource> {
2773 vec![
2774 Resource {
2775 base_path: "/users".to_string(),
2776 nav_label: "Backend Users".into(),
2777 list: backend_users_list_config(),
2778 form: None,
2779 permission: Some("backend.manage_users".to_string()),
2780 },
2781 Resource {
2782 base_path: "/roles".to_string(),
2783 nav_label: "Roles".into(),
2784 list: roles_list_config(),
2785 form: None,
2788 permission: Some("backend.manage_roles".to_string()),
2789 },
2790 Resource {
2791 base_path: "/audit-log".to_string(),
2792 nav_label: "Audit Log".into(),
2793 list: audit_log_list_config(),
2794 form: None,
2796 permission: Some("backend.view_audit_log".to_string()),
2797 },
2798 ]
2799}
2800
2801fn builtin_settings() -> Vec<settings::SettingsItem> {
2805 vec![
2806 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
2807 .description("Manage backend administrator accounts.")
2808 .category("Users")
2809 .order(10)
2810 .icon("users")
2811 .permission("backend.manage_users")
2812 .link("/users"),
2813 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
2814 .description("Manage roles and their permissions.")
2815 .category("Users")
2816 .order(20)
2817 .icon("shield")
2818 .permission("backend.manage_roles")
2819 .link("/roles"),
2820 settings::brand::settings_item(),
2821 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
2822 .description("Enable or disable installed plugins.")
2823 .category("System")
2824 .order(10)
2825 .icon("plug")
2826 .permission(plugins::MANAGE_PERMISSION)
2827 .link("/plugins"),
2828 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
2829 .description("Review the record of administrative changes.")
2830 .category("System")
2831 .order(20)
2832 .icon("history")
2833 .permission("backend.view_audit_log")
2834 .link("/audit-log"),
2835 ]
2836}
2837
2838fn backend_users_list_config() -> list::ListConfig {
2839 list::ListConfig {
2840 entity: "backend_users".to_string(),
2841 title: "Backend Users".into(),
2842 columns: vec![
2843 list::ListColumn::new("username", "Username"),
2844 list::ListColumn::new("email", "Email"),
2845 list::ListColumn::new("first_name", "First name"),
2846 list::ListColumn::new("last_name", "Last name"),
2847 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
2848 list::ListColumn::new("is_active", "Active").yes_no(),
2849 list::ListColumn::new("created_at", "Created").datetime(),
2850 ],
2851 order_by: "created_at".to_string(),
2852 edit_base: Some("/users".to_string()),
2855 filters: vec![
2856 list::ListFilter::boolean("is_active", "Active"),
2857 list::ListFilter::boolean("is_superuser", "Superuser"),
2858 ],
2859 ..Default::default()
2863 }
2864}
2865
2866fn roles_list_config() -> list::ListConfig {
2867 list::ListConfig {
2868 entity: "backend_roles".to_string(),
2869 title: "Roles".into(),
2870 columns: vec![
2871 list::ListColumn::new("code", "Code"),
2872 list::ListColumn::new("name", "Name"),
2873 list::ListColumn::new("created_at", "Created").datetime(),
2874 ],
2875 order_by: "created_at".to_string(),
2876 edit_base: Some("/roles".to_string()),
2877 creatable: true,
2878 deletable: true,
2879 ..Default::default()
2880 }
2881}
2882
2883fn audit_log_list_config() -> list::ListConfig {
2886 list::ListConfig {
2887 entity: "backend_audit_log".to_string(),
2888 title: "Audit Log".into(),
2889 columns: vec![
2890 list::ListColumn::new("created_at", "When").datetime(),
2891 list::ListColumn::new("actor_username", "Operator"),
2892 list::ListColumn::new("action", "Action"),
2893 list::ListColumn::new("target_type", "Target"),
2894 list::ListColumn::new("target_id", "Target ID"),
2895 ],
2896 order_by: "created_at".to_string(),
2897 per_page: 50,
2898 per_page_options: vec![25, 50, 100],
2899 exportable: true,
2902 ..Default::default()
2903 }
2904}
2905
2906#[derive(Template)]
2907#[template(path = "login.html")]
2908struct LoginTemplate {
2909 base: String,
2911 asset_urls: Arc<AssetUrls>,
2913 brand: String,
2914 error: Option<String>,
2915 i18n: Translator,
2918}
2919
2920fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
2923 match urls.get(key) {
2924 Some(path) => format!("{base}/assets/{path}"),
2925 None => {
2926 debug_assert!(false, "asset key `{key}` is not registered");
2927 format!("{base}/assets/{key}")
2928 }
2929 }
2930}
2931
2932impl LoginTemplate {
2933 fn asset(&self, key: &str) -> String {
2934 preauth_asset(&self.asset_urls, &self.base, key)
2935 }
2936 fn t(&self, source: &str) -> String {
2937 self.i18n.t(&Text::dynamic(source))
2938 }
2939 fn locale(&self) -> &str {
2940 self.i18n.locale()
2941 }
2942}
2943
2944#[derive(Template)]
2945#[template(path = "dashboard.html")]
2946struct DashboardTemplate {
2947 shell: Shell,
2948 username: String,
2949}
2950
2951#[derive(Template)]
2952#[template(path = "setup.html")]
2953struct SetupTemplate {
2954 base: String,
2956 asset_urls: Arc<AssetUrls>,
2958 brand: String,
2959 zones: Vec<TzOption>,
2960 error: Option<String>,
2961 i18n: Translator,
2963}
2964
2965impl SetupTemplate {
2966 fn asset(&self, key: &str) -> String {
2967 preauth_asset(&self.asset_urls, &self.base, key)
2968 }
2969 fn t(&self, source: &str) -> String {
2970 self.i18n.t(&Text::dynamic(source))
2971 }
2972 fn locale(&self) -> &str {
2973 self.i18n.locale()
2974 }
2975}
2976
2977#[derive(Template)]
2978#[template(path = "preferences.html")]
2979struct PreferencesTemplate {
2980 shell: Shell,
2981 zones: Vec<TzOption>,
2982 effective_tz: String,
2984 default_tz: String,
2986 inherits: bool,
2988 locales: Vec<LocaleOption>,
2989 default_locale: String,
2991 inherits_locale: bool,
2993 sessions: Vec<SessionRow>,
2995 error: Option<String>,
2996}
2997
2998struct SessionRow {
3000 id: String,
3001 started: String,
3002 last_seen: String,
3003 expires: String,
3004 device: String,
3007 ip_address: String,
3009 current: bool,
3010}
3011
3012struct TzOption {
3013 name: String,
3014 selected: bool,
3015}
3016
3017struct LocaleOption {
3018 code: String,
3020 name: String,
3022 selected: bool,
3023}
3024
3025#[derive(Clone)]
3026struct NavView {
3027 label: String,
3028 path: String,
3029 active: bool,
3030 icon: String,
3033}
3034
3035pub(crate) fn render<T: Template>(template: T) -> Response {
3037 match template.render() {
3038 Ok(html) => Html(html).into_response(),
3039 Err(e) => AdminError::from(e).into_response(),
3041 }
3042}
3043
3044pub(crate) fn render_error() -> Response {
3045 error::masked_500()
3046}
3047
3048pub(crate) fn not_found() -> Response {
3049 AdminError::NotFound.into_response()
3050}
3051
3052pub(crate) fn forbidden() -> Response {
3053 AdminError::Forbidden.into_response()
3054}
3055
3056#[cfg(test)]
3057mod tests {
3058 use super::*;
3059
3060 fn write_form() -> form::FormConfig {
3061 form::FormConfig {
3062 entity: "widgets".to_string(),
3063 title: "Widgets".into(),
3064 base_path: "/widgets".to_string(),
3065 id_field: "id".to_string(),
3066 fields: vec![],
3067 persist: None,
3068 timestamps: false,
3069 }
3070 }
3071
3072 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
3073 Resource {
3074 base_path: "/widgets".to_string(),
3075 nav_label: "Widgets".into(),
3076 list: backend_users_list_config(),
3077 form,
3078 permission: permission.map(str::to_string),
3079 }
3080 }
3081
3082 #[test]
3083 #[should_panic(expected = "must declare a permission")]
3084 fn write_resource_without_permission_aborts_boot() {
3085 let resource = resource_with(Some(write_form()), None);
3088 let _ = mount_resource(
3089 &resource,
3090 &field::builtin_registry(),
3091 &list::builtin_column_registry(),
3092 &std::collections::HashSet::new(),
3093 &persist::PersisterRegistry::new(),
3094 &[],
3095 );
3096 }
3097
3098 #[test]
3099 #[should_panic(expected = "uses unregistered type `nope`")]
3100 fn unregistered_column_type_aborts_boot() {
3101 let mut resource = resource_with(None, None);
3102 let mut score = list::ListColumn::new("score", "Score");
3103 score.column_type = "nope".into();
3104 resource.list.columns.push(score);
3105 let _ = mount_resource(
3106 &resource,
3107 &field::builtin_registry(),
3108 &list::builtin_column_registry(),
3109 &std::collections::HashSet::new(),
3110 &persist::PersisterRegistry::new(),
3111 &[],
3112 );
3113 }
3114
3115 #[test]
3116 fn read_only_resource_may_omit_permission() {
3117 let resource = resource_with(None, None);
3119 let _ = mount_resource(
3120 &resource,
3121 &field::builtin_registry(),
3122 &list::builtin_column_registry(),
3123 &std::collections::HashSet::new(),
3124 &persist::PersisterRegistry::new(),
3125 &[],
3126 );
3127 }
3128
3129 #[test]
3130 fn shell_localizes_through_the_translator_and_falls_back() {
3131 let shell = Shell::test();
3133 assert_eq!(shell.t("Save"), "Save");
3134 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
3135 }
3136
3137 #[test]
3138 fn shell_tf_interpolates_integer_arguments() {
3139 let shell = Shell::test();
3140 assert_eq!(
3141 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
3142 "Page 2 of 5"
3143 );
3144 }
3145
3146 #[test]
3147 fn display_tz_prefers_a_valid_operator_preference() {
3148 assert_eq!(
3149 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
3150 Tz::Asia__Kolkata
3151 );
3152 }
3153
3154 #[test]
3155 fn display_tz_falls_back_when_unset_or_invalid() {
3156 let default = Tz::Europe__London;
3157 assert_eq!(resolve_display_tz(None, default), default);
3159 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
3161 assert_eq!(resolve_display_tz(Some(""), default), default);
3162 }
3163
3164 #[test]
3165 fn accept_language_orders_by_weight_and_drops_wildcard() {
3166 assert_eq!(
3167 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
3168 vec!["en-US", "en", "kn"]
3169 );
3170 assert_eq!(
3172 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
3173 vec!["kn", "en"]
3174 );
3175 }
3176
3177 #[test]
3178 fn locale_chain_prefers_operator_then_header_then_default() {
3179 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
3181 assert_eq!(
3184 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
3185 vec!["kn", "ta", "hi", "en"]
3186 );
3187 assert_eq!(
3189 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
3190 vec!["kn", "en", "ta"]
3191 );
3192 assert_eq!(
3194 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
3195 vec!["kn", "en"]
3196 );
3197 }
3198
3199 #[test]
3200 fn locale_chain_skips_unserveable_and_defaults_to_en() {
3201 let loaded = ["hi".to_string()];
3203 assert_eq!(
3206 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
3207 vec!["en"]
3208 );
3209 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
3211 assert_eq!(
3213 resolve_locale_chain(Some("hi"), None, "en", &loaded),
3214 vec!["hi", "en"]
3215 );
3216 assert_eq!(
3218 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
3219 vec![laterite_core::PSEUDO_LOCALE, "en"]
3220 );
3221 }
3222
3223 #[test]
3224 fn page_assets_dedupe_preserve_order_and_classify() {
3225 let mut reg = AssetRegistry::new();
3226 reg.insert(
3227 "a.js",
3228 AdminAsset {
3229 mime: "text/javascript; charset=utf-8",
3230 bytes: std::borrow::Cow::Borrowed(b"//"),
3231 },
3232 );
3233 reg.insert(
3234 "b.css",
3235 AdminAsset {
3236 mime: "text/css; charset=utf-8",
3237 bytes: std::borrow::Cow::Borrowed(b"body{}"),
3238 },
3239 );
3240 let urls = asset_urls(®);
3243 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
3244 assert_eq!(assets.len(), 2);
3245 assert_eq!(
3246 assets[0].url,
3247 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
3248 );
3249 assert!(!assets[0].css);
3250 assert_eq!(
3251 assets[1].url,
3252 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
3253 );
3254 assert!(assets[1].css);
3255 assert_ne!(assets[0].url, assets[1].url);
3258 }
3259
3260 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
3264 laterite_core::testing::connect_test(&[]).await
3265 }
3266
3267 #[tokio::test]
3268 async fn builtin_migrations_create_the_admin_tables() {
3269 let (db, _guard) = empty_db().await;
3270 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
3271 .await
3272 .unwrap();
3273 for table in ["backend_users", "settings"] {
3277 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
3278 .fetch_optional(&db.pool)
3279 .await;
3280 assert!(
3281 probe.is_ok(),
3282 "{table} should exist after builtin_migrations"
3283 );
3284 }
3285 }
3286
3287 #[tokio::test]
3288 async fn brand_setting_overrides_config_and_blank_falls_back() {
3289 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
3290 let state = AdminState {
3291 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3292 db: db.clone(),
3293 nav: Arc::new(Vec::new()),
3294 settings: Arc::new(Vec::new()),
3295 permissions: Arc::new(builtin_permissions()),
3296 trusted_proxies: Arc::new(Vec::new()),
3297 icons: Arc::new(laterite_core::icons::IconSet::new()),
3298 sprite_url: Arc::from(""),
3299 admin_path: Arc::from("/admin"),
3300 secure_cookie: false,
3301 origin: Arc::from(""),
3302 timezone: Tz::UTC,
3303 default_locale: Arc::from("en"),
3304 catalogs: Arc::new(CatalogStore::default()),
3305 app_name: "Configured Name".to_string(),
3306 brand_cache: Arc::new(RwLock::new(None)),
3307 field_types: Arc::new(field::builtin_registry()),
3308 column_types: Arc::new(list::builtin_column_registry()),
3309 plugin_defined: Arc::new(laterite_core::Registry::new()),
3310 assets: Arc::new(builtin_assets()),
3311 asset_urls: Arc::new(asset_urls(&builtin_assets())),
3312 pickers: Arc::new(picker::PickerRegistry::new()),
3313 overrides: Arc::new(field::NoOverrides),
3314 };
3315
3316 assert_eq!(state.brand().await, "Configured Name");
3318
3319 settings::store::save(
3322 &db,
3323 &settings::BrandSetting {
3324 app_name: "Acme Corp".to_string(),
3325 },
3326 )
3327 .await
3328 .unwrap();
3329 state.invalidate_brand();
3330 assert_eq!(state.brand().await, "Acme Corp");
3331
3332 settings::store::save(
3334 &db,
3335 &settings::BrandSetting {
3336 app_name: " ".to_string(),
3337 },
3338 )
3339 .await
3340 .unwrap();
3341 state.invalidate_brand();
3342 assert_eq!(state.brand().await, "Configured Name");
3343 }
3344
3345 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
3346 settings::SettingsItem {
3347 code: code.to_string(),
3348 label: code.into(),
3349 description: String::new().into(),
3350 category: "General".into(),
3351 hint: None,
3352 order: 1,
3353 icon: None,
3354 permission: permission.map(str::to_string),
3355 link: None,
3356 fields: Vec::new(),
3357 route: None,
3358 }
3359 }
3360
3361 #[test]
3362 fn settings_visibility_respects_permissions() {
3363 let items = vec![
3364 settings_item("public", None),
3365 settings_item("gated", Some("backend.manage_users")),
3366 ];
3367
3368 let none = PermissionSet::new(false, Vec::<String>::new());
3370 let codes: Vec<String> = visible_settings(&items, &none)
3371 .into_iter()
3372 .map(|i| i.code)
3373 .collect();
3374 assert_eq!(codes, ["public"]);
3375
3376 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
3378 assert_eq!(visible_settings(&items, &granted).len(), 2);
3379
3380 let superuser = PermissionSet::new(true, Vec::<String>::new());
3382 assert_eq!(visible_settings(&items, &superuser).len(), 2);
3383 }
3384
3385 #[test]
3386 fn context_sidebar_follows_settings_links() {
3387 let items = builtin_settings();
3388 let superuser = PermissionSet::new(true, Vec::<String>::new());
3389 let tr = Translator::new("en");
3390 let sidebar = |path: &str| {
3391 resolve_nav_context(
3392 &[],
3393 &items,
3394 "/admin",
3395 &superuser,
3396 path,
3397 &tr,
3398 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
3399 )
3400 .0
3401 };
3402 let active_path = |path: &str| -> Option<String> {
3403 sidebar(path)
3404 .into_iter()
3405 .flat_map(|g| g.items)
3406 .find(|i| i.active)
3407 .map(|i| i.path)
3408 };
3409
3410 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
3412 assert_eq!(
3413 active_path("/admin/roles/42/edit").as_deref(),
3414 Some("/admin/roles")
3415 );
3416 assert!(!sidebar("/admin/settings").is_empty());
3418 assert_eq!(active_path("/admin/settings"), None);
3419 assert_eq!(
3424 active_path("/admin/settings/branding").as_deref(),
3425 Some("/admin/settings/branding")
3426 );
3427 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
3430 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
3431 assert!(sidebar("/admin").is_empty());
3433 }
3434
3435 #[test]
3436 fn active_nav_lights_the_right_tab() {
3437 let nav = vec![
3438 NavLink {
3439 label: "Dashboard".into(),
3440 path: "/admin".to_string(),
3441 icon: Some("layout-dashboard"),
3442 },
3443 NavLink {
3444 label: "Pages".into(),
3445 path: "/admin/pages".to_string(),
3446 icon: None,
3447 },
3448 NavLink {
3449 label: "Settings".into(),
3450 path: "/admin/settings".to_string(),
3451 icon: Some("settings"),
3452 },
3453 ];
3454
3455 assert_eq!(
3457 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
3458 Some("/admin")
3459 );
3460 assert_eq!(
3462 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
3463 Some("/admin/pages")
3464 );
3465 assert_eq!(
3467 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
3468 None
3469 );
3470 assert_eq!(
3473 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
3474 None
3475 );
3476 assert_eq!(
3479 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
3480 Some("/admin/settings")
3481 );
3482 assert_eq!(
3483 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
3484 Some("/admin/settings")
3485 );
3486 }
3487}
3488
3489#[cfg(test)]
3490mod session_cookie_tests {
3491 use super::*;
3492
3493 #[test]
3497 fn the_session_cookie_never_outlives_the_browser() {
3498 let cookie = session_cookie("tok".into(), "/admin", false);
3499 assert_eq!(cookie.max_age(), None);
3500 assert!(cookie.http_only().unwrap_or(false));
3501 assert_eq!(cookie.path(), Some("/admin"));
3502 }
3503
3504 #[test]
3507 fn the_remember_cookie_carries_its_own_lifetime() {
3508 let credential = laterite_auth::RememberCredential {
3509 cookie: "sel:ver".into(),
3510 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
3511 };
3512 let cookie = remember_cookie(credential, "/admin", true);
3513 let age = cookie.max_age().expect("a remember cookie must persist");
3514 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
3516 assert!(cookie.http_only().unwrap_or(false));
3517 assert!(cookie.secure().unwrap_or(false));
3518 }
3519
3520 #[test]
3522 fn the_removal_matches_the_cookie_it_clears() {
3523 let removal = remember_removal("/admin");
3524 assert_eq!(removal.name(), REMEMBER_COOKIE);
3525 assert_eq!(removal.path(), Some("/admin"));
3526 }
3527}