1mod audit;
18pub mod bootstrap;
19mod bulk;
20pub mod checklist;
21mod clientip;
22pub mod descriptor;
23mod error;
24mod export;
25pub mod field;
26pub mod form;
27pub mod html;
28pub mod http_cache;
29mod icons;
30pub(crate) mod keyed;
31pub mod list;
32pub mod persist;
33pub mod picker;
34pub mod plugins;
35mod roles;
36mod routemap;
37pub mod routes;
38mod session;
39pub mod settings;
40mod sql;
41mod upload;
42mod users;
43
44pub use axum;
52
53pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
54pub use error::AdminError;
55pub use session::{FlashLevel, SessionHandle};
56pub use upload::VerifiedUpload;
57
58use std::collections::HashMap;
59use std::sync::{Arc, RwLock};
60
61use askama::Template;
62use axum::body::Body;
63use axum::extract::{Path, Query, Request, State};
64use axum::http::{header, HeaderMap};
65use axum::middleware::{self, Next};
66use axum::response::{Html, IntoResponse, Redirect, Response};
67use axum::routing::{any, get, post};
68use axum::{Extension, Form, Router};
69use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
70use chrono_tz::{Tz, TZ_VARIANTS};
71use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
72use laterite_core::{t, CatalogStore, Db, Text, Translator};
73use serde::{Deserialize, Serialize};
74
75pub trait AdminRegistry {
81 fn add_resource(&mut self, resource: Resource);
83 fn add_permission(&mut self, permission: Permission);
85 fn add_settings(&mut self, item: settings::SettingsItem);
87 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
89 fn add_persister(&mut self, persister: persist::PersisterReg);
91 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
93 fn add_screen(&mut self, screen: routes::ScreenReg);
96 fn add_public_route(&mut self, route: routes::PublicRouteReg);
99}
100
101impl AdminRegistry for laterite_core::Registry {
102 fn add_resource(&mut self, resource: Resource) {
103 self.add(resource);
104 }
105 fn add_permission(&mut self, permission: Permission) {
106 self.add(permission);
107 }
108 fn add_settings(&mut self, item: settings::SettingsItem) {
109 self.add(item);
110 }
111 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
112 self.add(source);
113 }
114 fn add_screen(&mut self, screen: routes::ScreenReg) {
115 self.add(screen);
116 }
117 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
118 self.add(route);
119 }
120 fn add_persister(&mut self, persister: persist::PersisterReg) {
121 self.add(persister);
122 }
123 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
124 self.add(field_type);
125 }
126}
127
128const SESSION_COOKIE: &str = "laterite_session";
129const REMEMBER_COOKIE: &str = "laterite_remember";
133
134pub(crate) const ICON_SPRITE: &str = "icons.svg";
137
138#[derive(Clone)]
140pub(crate) struct AdminState {
141 auth: AuthService,
142 db: Db,
143 nav: Arc<Vec<NavLink>>,
144 settings: Arc<Vec<settings::SettingsItem>>,
145 permissions: Arc<Vec<Permission>>,
146 admin_path: Arc<str>,
150 secure_cookie: bool,
151 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
153 icons: Arc<laterite_core::icons::IconSet>,
155 sprite_url: Arc<str>,
157 origin: Arc<str>,
159 timezone: Tz,
160 default_locale: Arc<str>,
164 catalogs: Arc<CatalogStore>,
167 app_name: String,
170 brand_cache: Arc<RwLock<Option<String>>>,
174 asset_urls: Arc<AssetUrls>,
176 field_types: Arc<field::FieldRegistry>,
179 column_types: Arc<list::ColumnRegistry>,
182 plugin_defined: Arc<laterite_core::Registry>,
185 assets: Arc<AssetRegistry>,
187 pickers: Arc<picker::PickerRegistry>,
190 overrides: Arc<dyn field::OverrideResolver>,
194}
195
196impl AdminState {
197 #[cfg(test)]
198 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
199 Self {
200 auth,
201 db,
202 nav: Arc::new(Vec::new()),
203 settings: Arc::new(Vec::new()),
204 permissions: Arc::new(builtin_permissions()),
205 trusted_proxies: Arc::new(Vec::new()),
206 icons: Arc::new(laterite_core::icons::IconSet::new()),
207 sprite_url: Arc::from(""),
208 admin_path: Arc::from("/admin"),
209 secure_cookie: false,
210 origin: Arc::from(""),
211 timezone: Tz::UTC,
212 default_locale: Arc::from("en"),
213 catalogs: Arc::new(CatalogStore::default()),
214 app_name: "Laterite".to_string(),
215 brand_cache: Arc::new(RwLock::new(None)),
216 field_types: Arc::new(field::builtin_registry()),
217 column_types: Arc::new(list::builtin_column_registry()),
218 plugin_defined: Arc::new(laterite_core::Registry::new()),
219 assets: Arc::new(builtin_assets()),
220 asset_urls: Arc::new(asset_urls(&builtin_assets())),
221 pickers: Arc::new(picker::PickerRegistry::new()),
222 overrides: Arc::new(field::NoOverrides),
223 }
224 }
225
226 async fn brand(&self) -> String {
230 {
231 let cached = self.brand_cache.read().unwrap().clone();
232 if let Some(name) = cached {
233 return name;
234 }
235 }
236 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
237 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
238 _ => self.app_name.clone(),
239 };
240 *self.brand_cache.write().unwrap() = Some(resolved.clone());
241 resolved
242 }
243
244 fn invalidate_brand(&self) {
246 *self.brand_cache.write().unwrap() = None;
247 }
248}
249
250#[derive(Clone)]
256#[non_exhaustive]
257pub struct AdminConfig {
258 pub secure_cookie: bool,
261 pub trusted_proxies: Vec<String>,
265 pub timezone: String,
269 pub locale: String,
273 pub app_name: String,
277 pub path: String,
281 pub origin: String,
286}
287
288impl Default for AdminConfig {
289 fn default() -> Self {
290 Self {
291 secure_cookie: false,
292 trusted_proxies: Vec::new(),
293 timezone: "UTC".to_string(),
294 locale: "en".to_string(),
295 app_name: "Laterite".to_string(),
296 path: "/admin".to_string(),
297 origin: String::new(),
298 }
299 }
300}
301
302#[derive(Clone)]
303struct NavLink {
304 label: Text,
305 path: String,
306 icon: Option<&'static str>,
309}
310
311#[derive(Clone)]
316pub(crate) struct Shell {
317 pub(crate) base: String,
321 brand: String,
325 nav: Vec<NavView>,
326 full_name: String,
327 initial: String,
328 tz: Tz,
332 i18n: Translator,
336 sidebar: Vec<settings::CategoryView>,
340 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
342 pub(crate) sprite_url: Arc<str>,
345 site_url: String,
349 asset_urls: Arc<AssetUrls>,
353 pub(crate) csrf_token: String,
357 pub(crate) flash: Vec<FlashLine>,
361 pub(crate) assets: Vec<PageAsset>,
365}
366
367#[derive(Clone)]
369pub(crate) struct PageAsset {
370 pub(crate) url: String,
371 pub(crate) css: bool,
372}
373
374#[derive(Clone)]
377pub(crate) struct FlashLine {
378 pub(crate) level: session::FlashLevel,
379 pub(crate) text: String,
380 pub(crate) sticky: bool,
382}
383
384impl Shell {
385 #[allow(clippy::too_many_arguments)]
386 fn new(
387 base: &str,
388 brand: String,
389 nav: &[NavLink],
390 user: &AuthenticatedUser,
391 default_tz: Tz,
392 sidebar: Vec<settings::CategoryView>,
393 active_nav: Option<&str>,
394 csrf_token: String,
395 flash: Vec<session::Flash>,
396 i18n: Translator,
397 asset_urls: Arc<AssetUrls>,
398 site_url: String,
399 icon_set: Arc<laterite_core::icons::IconSet>,
400 sprite_url: Arc<str>,
401 ) -> Self {
402 let full_name = user.user.full_name();
403 let initial = full_name
404 .chars()
405 .next()
406 .map(|c| c.to_uppercase().to_string())
407 .unwrap_or_else(|| "?".to_string());
408 let flash = flash
411 .into_iter()
412 .map(|f| FlashLine {
413 sticky: f.sticky,
414 level: f.level,
415 text: i18n.t(&f.text),
416 })
417 .collect();
418 let nav = nav
419 .iter()
420 .map(|n| NavView {
421 label: i18n.t(&n.label),
422 path: n.path.clone(),
423 active: active_nav == Some(n.path.as_str()),
424 icon: n
425 .icon
426 .map(|name| icons::svg(&icon_set, &sprite_url, Some(name)))
427 .unwrap_or_default(),
428 })
429 .collect();
430 Shell {
431 asset_urls,
432 icon_set,
433 sprite_url,
434 site_url,
435 base: base.to_string(),
436 brand,
437 nav,
438 full_name,
439 initial,
440 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
441 i18n,
442 sidebar,
443 csrf_token,
444 flash,
445 assets: Vec::new(),
446 }
447 }
448
449 pub(crate) fn site_url(&self) -> &str {
451 &self.site_url
452 }
453
454 pub(crate) fn icon(&self, name: &str) -> String {
456 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
457 }
458
459 pub(crate) fn asset(&self, key: &str) -> String {
464 match self.asset_urls.get(key) {
465 Some(path) => format!("{}/assets/{path}", self.base),
466 None => {
467 debug_assert!(false, "asset key `{key}` is not registered");
468 format!("{}/assets/{key}", self.base)
469 }
470 }
471 }
472
473 pub(crate) fn t(&self, source: &str) -> String {
476 self.i18n.t(&Text::dynamic(source))
477 }
478
479 pub(crate) fn tt(&self, text: &Text) -> String {
482 self.i18n.t(text)
483 }
484
485 pub(crate) fn i18n(&self) -> &Translator {
488 &self.i18n
489 }
490
491 pub(crate) fn locale(&self) -> &str {
493 self.i18n.locale()
494 }
495
496 pub(crate) fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
500 let mut text = Text::dynamic(source);
501 for (name, value) in args {
502 text = text.arg(*name, *value);
503 }
504 self.i18n.t(&text)
505 }
506
507 pub(crate) fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
510 let mut text = Text::dynamic(source);
511 for (name, value) in args {
512 text = text.arg(*name, *value);
513 }
514 self.i18n.t(&text)
515 }
516
517 #[cfg(test)]
518 pub(crate) fn test() -> Self {
519 Shell {
520 asset_urls: Arc::new(asset_urls(&builtin_assets())),
521 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
522 sprite_url: Arc::from("/admin/assets/icons.svg"),
523 site_url: "http://localhost".to_string(),
524 base: "/admin".to_string(),
525 brand: "Laterite".to_string(),
526 nav: Vec::new(),
527 full_name: "Test Operator".to_string(),
528 initial: "T".to_string(),
529 tz: Tz::UTC,
530 i18n: Translator::new("en"),
531 sidebar: Vec::new(),
532 csrf_token: "test-csrf-token".to_string(),
533 flash: Vec::new(),
534 assets: Vec::new(),
535 }
536 }
537}
538
539fn settings_context(
546 visible: &[settings::SettingsItem],
547 admin_path: &str,
548 path: &str,
549) -> (bool, Option<String>) {
550 if path == format!("{admin_path}/settings") {
551 return (true, None);
552 }
553 let own = visible
559 .iter()
560 .filter(|i| i.link.is_none())
561 .find(|i| path == format!("{admin_path}{}", i.route()))
562 .map(|i| i.code.clone());
563 if own.is_some() {
564 return (true, own);
565 }
566 let linked = visible
567 .iter()
568 .filter_map(|i| {
569 i.link
570 .as_deref()
571 .map(|link| (format!("{admin_path}{link}"), &i.code))
572 })
573 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
574 .max_by_key(|(link, _)| link.len())
575 .map(|(_, code)| code.clone());
576 (linked.is_some(), linked)
577}
578
579fn active_nav_path(
587 nav: &[NavLink],
588 admin_path: &str,
589 in_settings_context: bool,
590 path: &str,
591) -> Option<String> {
592 if in_settings_context {
593 return Some(format!("{admin_path}/settings"));
594 }
595 nav.iter()
596 .filter(|n| {
597 path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
598 })
599 .max_by_key(|n| n.path.len())
600 .map(|n| n.path.clone())
601}
602
603fn resolve_nav_context(
608 nav: &[NavLink],
609 items: &[settings::SettingsItem],
610 admin_path: &str,
611 perms: &PermissionSet,
612 path: &str,
613 tr: &Translator,
614 icons: icons::Icons<'_>,
615) -> (Vec<settings::CategoryView>, Option<String>) {
616 let visible = visible_settings(items, perms);
617 let (in_context, active) = settings_context(&visible, admin_path, path);
618 let sidebar = if in_context {
619 settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons)
620 } else {
621 Vec::new()
622 };
623 let active_nav = active_nav_path(nav, admin_path, in_context, path);
624 (sidebar, active_nav)
625}
626
627fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
631 preference
632 .and_then(|name| name.parse::<Tz>().ok())
633 .unwrap_or(default_tz)
634}
635
636fn zone_offered(name: &str) -> bool {
638 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
639}
640
641fn base_lang(tag: &str) -> String {
644 tag.split(['-', '_'])
645 .next()
646 .unwrap_or(tag)
647 .to_ascii_lowercase()
648}
649
650fn is_serveable(base: &str, serveable: &[String]) -> bool {
656 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
657}
658
659fn default_locale(configured: &str, serveable: &[String]) -> String {
662 let base = base_lang(configured);
663 if is_serveable(&base, serveable) {
664 base
665 } else {
666 "en".to_string()
667 }
668}
669
670fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
674 let mut out = vec!["en".to_string()];
675 out.extend(catalogs.locales());
676 out
677}
678
679fn locale_name(code: &str) -> String {
684 if code == "en" {
685 "English".to_string()
686 } else {
687 code.to_string()
688 }
689}
690
691fn parse_accept_language(header: &str) -> Vec<String> {
694 let mut items: Vec<(f32, String)> = header
695 .split(',')
696 .filter_map(|part| {
697 let mut bits = part.split(';');
698 let tag = bits.next()?.trim();
699 if tag.is_empty() || tag == "*" {
700 return None;
701 }
702 let q = bits
703 .find_map(|p| p.trim().strip_prefix("q="))
704 .and_then(|q| q.parse::<f32>().ok())
705 .unwrap_or(1.0);
706 Some((q, tag.to_string()))
707 })
708 .collect();
709 items.sort_by(|a, b| b.0.total_cmp(&a.0));
711 items.into_iter().map(|(_, tag)| tag).collect()
712}
713
714fn resolve_locale_chain(
720 preference: Option<&str>,
721 accept_language: Option<&str>,
722 default: &str,
723 serveable: &[String],
724) -> Vec<String> {
725 let mut chain: Vec<String> = Vec::new();
726 let consider = |tag: &str, chain: &mut Vec<String>| {
727 let base = base_lang(tag);
728 if is_serveable(&base, serveable) && !chain.contains(&base) {
729 chain.push(base);
730 }
731 };
732 if let Some(pref) = preference {
733 consider(pref, &mut chain);
734 }
735 if let Some(header) = accept_language {
736 for tag in parse_accept_language(header) {
737 consider(&tag, &mut chain);
738 }
739 }
740 consider(default, &mut chain);
741 consider("en", &mut chain);
744 chain
745}
746
747fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
751 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
752 let serveable = state.catalogs.locales();
753 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
754 Translator::with_chain(chain, state.catalogs.clone())
755}
756
757#[derive(Serialize)]
765#[non_exhaustive]
766pub struct Resource {
767 pub base_path: String,
773 pub nav_label: Text,
775 pub list: list::ListConfig,
776 pub form: Option<form::FormConfig>,
777 pub permission: Option<String>,
784}
785
786#[derive(Clone, Serialize)]
791#[non_exhaustive]
792pub struct Permission {
793 pub code: String,
794 pub label: Text,
796 pub group: Text,
797 pub default_roles: Vec<String>,
800 pub renamed_from: Vec<String>,
803}
804
805pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
806
807impl Permission {
808 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
811 Self {
812 code: code.into(),
813 label: label.into(),
814 group: group.into(),
815 default_roles: Vec::new(),
816 renamed_from: Vec::new(),
817 }
818 }
819
820 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
826 where
827 I: IntoIterator<Item = S>,
828 S: Into<String>,
829 {
830 self.renamed_from = codes.into_iter().map(Into::into).collect();
831 self
832 }
833
834 pub fn roles<I, S>(mut self, roles: I) -> Self
837 where
838 I: IntoIterator<Item = S>,
839 S: Into<String>,
840 {
841 self.default_roles = roles.into_iter().map(Into::into).collect();
842 self
843 }
844}
845
846pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
852 let permissions: Vec<Permission> = builtin_permissions()
853 .into_iter()
854 .chain(contributed.iter().cloned())
855 .collect();
856 let mut out: Vec<(String, String)> = Vec::new();
857 for permission in &permissions {
858 for old in &permission.renamed_from {
859 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
860 panic!(
861 "permissions `{taken}` and `{}` both claim the old code `{old}`",
862 permission.code
863 );
864 }
865 if permissions.iter().any(|p| &p.code == old) {
866 panic!(
867 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
868 permission.code
869 );
870 }
871 out.push((old.clone(), permission.code.clone()));
872 }
873 }
874 out
875}
876
877pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
883 let permissions: Vec<Permission> = builtin_permissions()
886 .into_iter()
887 .chain(contributed.iter().cloned())
888 .collect();
889 let held = |role: &str| -> Vec<String> {
890 permissions
891 .iter()
892 .filter(|p| {
893 if p.default_roles.is_empty() {
894 role == ROLE_ADMIN
895 } else {
896 p.default_roles.iter().any(|r| r == role)
897 }
898 })
899 .map(|p| p.code.clone())
900 .collect()
901 };
902 vec![
903 laterite_auth::store::SystemRole {
904 code: ROLE_ADMIN,
905 name: "Administrator",
906 description: "Administers the panel itself: operators, roles and settings.",
907 permissions: held(ROLE_ADMIN),
908 },
909 laterite_auth::store::SystemRole {
910 code: ROLE_EDITOR,
911 name: "Editor",
912 description: "Works with the content the application declares.",
913 permissions: held(ROLE_EDITOR),
914 },
915 ]
916}
917
918impl Resource {
919 pub fn new(
924 base_path: impl Into<String>,
925 nav_label: impl Into<Text>,
926 list: list::ListConfig,
927 ) -> Self {
928 Self {
929 base_path: base_path.into(),
930 nav_label: nav_label.into(),
931 list,
932 form: None,
933 permission: None,
934 }
935 }
936
937 pub fn form(mut self, form: form::FormConfig) -> Self {
940 self.form = Some(form);
941 self
942 }
943
944 pub fn permission(mut self, permission: impl Into<String>) -> Self {
946 self.permission = Some(permission.into());
947 self
948 }
949}
950
951struct SystemRoleGuard;
957
958#[laterite_core::strata::async_trait]
959impl laterite_core::ModelListener for SystemRoleGuard {
960 async fn before_delete(
961 &self,
962 cx: &mut laterite_core::SaveCx<'_>,
963 rec: &laterite_core::Record,
964 ) -> Result<(), Text> {
965 let Some(id) = rec.id() else {
966 return Ok(());
967 };
968 let backend = cx.backend();
970 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
971 Ok(true) => Err(t!(
972 "The framework maintains this role; it cannot be deleted."
973 )),
974 Err(_) => Err(t!(
976 "Could not check whether this role is maintained by the framework."
977 )),
978 Ok(false) => Ok(()),
979 }
980 }
981}
982
983fn builtin_permissions() -> Vec<Permission> {
986 vec![
989 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
990 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
991 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
992 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
993 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
994 Permission::new("backend.view_access_log", "View the access log", "Backend"),
995 ]
996}
997
998pub fn descriptor_sources() -> Vec<String> {
1004 let mut out = Vec::new();
1005 out.extend(laterite_core::collect_sources(&builtin_resources()));
1006 out.extend(laterite_core::collect_sources(&builtin_settings()));
1007 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1008 out.sort();
1009 out.dedup();
1010 out
1011}
1012
1013pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1028 builtin_modules().iter().map(|m| m.migrations()).collect()
1029}
1030
1031pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1035 vec![
1036 Box::new(laterite_auth::AuthModule),
1037 Box::new(settings::SettingsModule),
1038 Box::new(plugins::PluginsModule),
1039 ]
1040}
1041
1042#[derive(Default)]
1066pub struct Contributions {
1067 pub resources: Vec<Resource>,
1069 pub settings: Vec<settings::SettingsItem>,
1071 pub permissions: Vec<Permission>,
1073 pub picker_sources: Vec<picker::PickerSourceReg>,
1075 pub persisters: Vec<persist::PersisterReg>,
1077 pub listeners: Vec<laterite_core::ModelListenerReg>,
1079 pub field_types: Vec<field::FieldTypeReg>,
1083 pub column_types: Vec<list::ColumnTypeReg>,
1084 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1087 pub screens: Vec<routes::ScreenReg>,
1089 pub public_routes: Vec<routes::PublicRouteReg>,
1091 pub plugin_defined: Arc<laterite_core::Registry>,
1096}
1097
1098pub fn router(
1099 auth: AuthService,
1100 db: Db,
1101 contributions: Contributions,
1102 config: AdminConfig,
1103 catalogs: Arc<CatalogStore>,
1104) -> Router {
1105 let Contributions {
1106 resources: app_resources,
1107 settings: app_settings,
1108 permissions: app_permissions,
1109 picker_sources: app_picker_sources,
1110 persisters: app_persisters,
1111 listeners: app_listeners,
1112 field_types: app_field_types,
1113 column_types: app_column_types,
1114 icons: app_icons,
1115 screens: app_screens,
1116 public_routes: app_public,
1117 plugin_defined,
1118 } = contributions;
1119 let admin_path = normalize_path(&config.path);
1120
1121 let mut resources = builtin_resources();
1122 let mut app_resources = app_resources;
1123 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1127 prefix_resource(&admin_path, resource);
1128 }
1129
1130 let mut settings = builtin_settings();
1131 settings.extend(app_settings);
1132 let mut app_listeners = app_listeners;
1135 app_listeners.insert(
1136 0,
1137 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1138 );
1139
1140 let mut permissions = builtin_permissions();
1141 permissions.extend(app_permissions);
1142 let permission_codes: std::collections::HashSet<String> =
1144 permissions.iter().map(|p| p.code.clone()).collect();
1145
1146 let mut nav = vec![NavLink {
1150 label: "Dashboard".into(),
1151 path: admin_path.clone(),
1152 icon: Some("layout-dashboard"),
1153 }];
1154 for resource in &app_resources {
1155 nav.push(NavLink {
1156 label: resource.nav_label.clone(),
1157 path: resource.base_path.clone(),
1158 icon: None,
1159 });
1160 }
1161 for reg in &app_screens {
1164 if let Some(label) = ®.nav_label {
1165 nav.push(NavLink {
1166 label: label.clone(),
1167 path: format!("{admin_path}{}", reg.base_path),
1168 icon: None,
1169 });
1170 }
1171 }
1172 nav.push(NavLink {
1173 label: "Settings".into(),
1174 path: format!("{admin_path}/settings"),
1175 icon: Some("settings"),
1176 });
1177 resources.extend(app_resources);
1178
1179 let app_name = if config.app_name.trim().is_empty() {
1180 "Laterite".to_string()
1181 } else {
1182 config.app_name.clone()
1183 };
1184
1185 let mut pickers = picker::PickerRegistry::new();
1188 for reg in app_picker_sources {
1189 let name = reg.name.clone();
1190 if !field::is_name(&name, true) {
1191 panic!("invalid picker source name `{name}`");
1192 }
1193 if pickers.insert(name.clone(), reg).is_some() {
1194 panic!("duplicate picker source `{name}`");
1195 }
1196 }
1197
1198 let pickers = Arc::new(pickers);
1202 let mut field_types = field::builtin_registry();
1203 field_types.insert(
1204 "reference".to_string(),
1205 Arc::new(field::RefPickerField::new(pickers.clone())),
1206 );
1207
1208 let mut persisters = persist::PersisterRegistry::new();
1211 for reg in app_persisters {
1212 let name = reg.name.clone();
1213 if !field::is_name(&name, true) {
1214 panic!("invalid persister name `{name}`");
1215 }
1216 if persisters.insert(name.clone(), reg.persister).is_some() {
1217 panic!("duplicate persister `{name}`");
1218 }
1219 }
1220
1221 for reg in app_field_types {
1227 let name = reg.name().to_string();
1228 if !field::is_name(&name, true) {
1229 panic!("invalid field type name `{name}`");
1230 }
1231 if field_types.insert(name.clone(), reg.field_type).is_some() {
1232 panic!("duplicate field type `{name}`");
1233 }
1234 }
1235
1236 let mut icon_set = laterite_core::icons::IconSet::new();
1240 for (owner, reg) in app_icons {
1241 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1242 panic!("module `{owner}` contributed an invalid icon: {e}");
1243 }
1244 }
1245
1246 let mut assets = builtin_assets();
1249 assets.insert(
1250 ICON_SPRITE,
1251 AdminAsset {
1252 mime: "image/svg+xml",
1253 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1254 },
1255 );
1256
1257 for item in &settings {
1261 icons::require(
1262 &icon_set,
1263 &format!("settings item `{}`", item.code),
1264 item.icon.as_deref(),
1265 );
1266 }
1267 for link in &nav {
1268 icons::require(&icon_set, &format!("menu entry `{}`", link.path), link.icon);
1269 }
1270
1271 let mut column_types = list::builtin_column_registry();
1275 for reg in app_column_types {
1276 let name = reg.name().to_string();
1277 if !field::is_name(&name, true) {
1278 panic!("invalid column type name `{name}`");
1279 }
1280 if column_types.insert(name.clone(), reg.column_type).is_some() {
1281 panic!("duplicate column type `{name}`");
1282 }
1283 }
1284
1285 let settings = Arc::new(settings);
1286 let asset_urls_arc = Arc::new(asset_urls(&assets));
1288 let sprite_url: Arc<str> = Arc::from(
1289 asset_urls_arc
1290 .get(ICON_SPRITE)
1291 .map(|p| format!("{admin_path}/assets/{p}"))
1292 .unwrap_or_default()
1293 .as_str(),
1294 );
1295 let icons_arc = Arc::new(icon_set);
1296 let state = AdminState {
1297 auth,
1298 db,
1299 nav: Arc::new(nav),
1300 settings: settings.clone(),
1301 permissions: Arc::new(permissions),
1302 admin_path: Arc::from(admin_path.as_str()),
1303 secure_cookie: config.secure_cookie,
1304 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1305 icons: icons_arc.clone(),
1306 sprite_url: sprite_url.clone(),
1307 origin: Arc::from(config.origin.trim_end_matches('/')),
1308 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1309 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1310 catalogs,
1311 app_name,
1312 brand_cache: Arc::new(RwLock::new(None)),
1313 field_types: Arc::new(field_types),
1314 column_types: Arc::new(column_types),
1315 plugin_defined,
1316 assets: Arc::new(assets),
1317 asset_urls: asset_urls_arc.clone(),
1318 pickers,
1319 overrides: Arc::new(field::NoOverrides),
1320 };
1321
1322 let mut protected = Router::new().route(&admin_path, get(dashboard));
1323 for resource in &resources {
1324 protected = protected.merge(mount_resource(
1325 resource,
1326 &state.field_types,
1327 &state.column_types,
1328 &permission_codes,
1329 &persisters,
1330 &app_listeners,
1331 ));
1332 }
1333 for reg in &app_screens {
1337 let base = format!("{admin_path}{}", reg.base_path);
1338 let ctx = routes::RouteCtx::new(
1339 state.db.clone(),
1340 &base,
1341 &admin_path,
1342 &state.origin,
1343 state.plugin_defined.clone(),
1344 state.auth.events().clone(),
1345 );
1346 protected = protected.merge(guard_with_permission(
1347 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1348 ®.permission,
1349 ));
1350 }
1351
1352 protected = protected.merge(guard_with_permission(
1355 Router::new()
1356 .route(
1357 &format!("{admin_path}/roles/new"),
1358 get(roles::new_form).post(roles::create),
1359 )
1360 .route(
1361 &format!("{admin_path}/roles/{{id}}/edit"),
1362 get(roles::edit_form).post(roles::update),
1363 ),
1364 "backend.manage_roles",
1365 ));
1366 protected = protected.merge(guard_with_permission(
1369 Router::new()
1370 .route(
1371 &format!("{admin_path}/users/new"),
1372 get(users::new_form).post(users::create),
1373 )
1374 .route(
1375 &format!("{admin_path}/users/{{id}}/edit"),
1376 get(users::edit_form).post(users::update),
1377 )
1378 .route(
1379 &format!("{admin_path}/users/{{id}}/active"),
1380 post(users::set_active),
1381 )
1382 .route(
1383 &format!("{admin_path}/users/{{id}}/password"),
1384 post(users::reset_password),
1385 )
1386 .route(
1387 &format!("{admin_path}/users/{{id}}/unlock"),
1388 post(users::unlock),
1389 ),
1390 "backend.manage_users",
1391 ));
1392 protected = protected.merge(guard_with_permission(
1395 Router::new()
1396 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1397 .route(
1398 &format!("{admin_path}/plugins/toggle"),
1399 post(plugins::toggle),
1400 ),
1401 plugins::MANAGE_PERMISSION,
1402 ));
1403 protected = protected
1404 .route(&format!("{admin_path}/settings"), get(settings_index))
1405 .route(
1406 &format!("{admin_path}/preferences"),
1407 get(preferences_form).post(preferences_update),
1408 )
1409 .route(
1410 &format!("{admin_path}/preferences/password"),
1411 post(password_update),
1412 )
1413 .route(
1414 &format!("{admin_path}/preferences/sessions/revoke"),
1415 post(session_revoke),
1416 )
1417 .route(
1418 &format!("{admin_path}/preferences/sessions/revoke-others"),
1419 post(session_revoke_others),
1420 )
1421 .route(
1424 &format!("{admin_path}/pickers/{{source}}/search"),
1425 any(picker::search),
1426 )
1427 .route(
1428 &format!("{admin_path}/pickers/{{source}}/resolve"),
1429 any(picker::resolve),
1430 )
1431 .route(&format!("{admin_path}/logout"), post(logout));
1432
1433 for item in settings.iter().filter(|i| i.link.is_none()) {
1437 let path = format!("{admin_path}{}", item.route());
1438 let code = item.code.clone();
1439 let edit_code = code.clone();
1440 protected = protected.route(
1441 &path,
1442 get(
1443 move |State(state): State<AdminState>,
1444 Extension(shell): Extension<Shell>,
1445 Extension(user): Extension<AuthenticatedUser>| {
1446 let code = edit_code.clone();
1447 async move { settings_edit(state, shell, user, code).await }
1448 },
1449 )
1450 .post(
1451 move |State(state): State<AdminState>,
1452 Extension(shell): Extension<Shell>,
1453 Extension(user): Extension<AuthenticatedUser>,
1454 Extension(session): Extension<session::SessionHandle>,
1455 Form(data): Form<HashMap<String, String>>| {
1456 let code = code.clone();
1457 async move { settings_update(state, shell, user, session, code, data).await }
1458 },
1459 ),
1460 );
1461 }
1462
1463 protected
1464 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1465 .route(
1469 &format!("{admin_path}/login"),
1470 get(login_form).post(login_submit),
1471 )
1472 .route(
1473 &format!("{admin_path}/setup"),
1474 get(setup_form).post(setup_submit),
1475 )
1476 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1477 .merge(mount_public(
1481 &app_public,
1482 &state.db,
1483 &admin_path,
1484 &state.origin,
1485 &state.plugin_defined,
1486 state.auth.events(),
1487 ))
1488 .fallback(not_found_fallback)
1490 .layer(middleware::from_fn_with_state(
1493 state.clone(),
1494 enforce_origin,
1495 ))
1496 .layer(middleware::from_fn_with_state(
1499 state.clone(),
1500 capture_client,
1501 ))
1502 .layer(
1507 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1508 header::CACHE_CONTROL,
1509 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1510 ),
1511 )
1512 .layer(
1516 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1517 header::VARY,
1518 axum::http::HeaderValue::from_static("HX-Request"),
1519 ),
1520 )
1521 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1522 handle_panic,
1523 ))
1524 .with_state(state)
1525}
1526
1527async fn not_found_fallback() -> AdminError {
1529 AdminError::NotFound
1530}
1531
1532fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1534 let cause = err
1535 .downcast_ref::<&str>()
1536 .map(|s| s.to_string())
1537 .or_else(|| err.downcast_ref::<String>().cloned())
1538 .unwrap_or_else(|| "unknown panic".to_string());
1539 tracing::error!(panic = cause, "admin handler panicked");
1540 error::masked_500()
1541}
1542
1543pub fn normalize_path(path: &str) -> String {
1548 let trimmed = path.trim().trim_matches('/');
1549 if trimmed.is_empty() {
1550 "/admin".to_string()
1551 } else {
1552 format!("/{trimmed}")
1553 }
1554}
1555
1556fn mount_public(
1560 regs: &[routes::PublicRouteReg],
1561 db: &Db,
1562 admin_path: &str,
1563 origin: &str,
1564 plugin_defined: &Arc<laterite_core::Registry>,
1565 events: &laterite_core::Events,
1566) -> Router<AdminState> {
1567 let mut router = Router::new();
1568 for reg in regs {
1569 let ctx = routes::RouteCtx::new(
1570 db.clone(),
1571 ®.path,
1572 admin_path,
1573 origin,
1574 plugin_defined.clone(),
1575 events.clone(),
1576 );
1577 router = router.nest_service(
1582 ®.path,
1583 reg.route.mount(&ctx).fallback(not_found_fallback),
1584 );
1585 }
1586 router
1587}
1588
1589pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1592 let old = resource.base_path.clone();
1593 resource.base_path = base.to_string();
1594 if let Some(edit_base) = &mut resource.list.edit_base {
1595 if edit_base == &old {
1596 *edit_base = base.to_string();
1597 }
1598 }
1599 if let Some(form) = &mut resource.form {
1600 if form.base_path == old {
1601 form.base_path = base.to_string();
1602 }
1603 }
1604}
1605
1606fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1607 resource.base_path = format!("{admin_path}{}", resource.base_path);
1608 if let Some(edit_base) = &mut resource.list.edit_base {
1609 *edit_base = format!("{admin_path}{edit_base}");
1610 }
1611 if let Some(form) = &mut resource.form {
1612 form.base_path = format!("{admin_path}{}", form.base_path);
1613 }
1614}
1615
1616pub(crate) struct AdminAsset {
1618 pub mime: &'static str,
1619 pub bytes: std::borrow::Cow<'static, [u8]>,
1623}
1624
1625pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1628
1629pub(crate) type AssetUrls = HashMap<&'static str, String>;
1633
1634pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
1636 registry
1637 .iter()
1638 .map(|(&key, asset)| {
1639 (
1640 key,
1641 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
1642 )
1643 })
1644 .collect()
1645}
1646
1647pub(crate) fn page_assets(
1654 keys: &[&str],
1655 base: &str,
1656 registry: &AssetRegistry,
1657 urls: &AssetUrls,
1658) -> Vec<PageAsset> {
1659 let mut seen = std::collections::HashSet::new();
1660 let mut out = Vec::new();
1661 for &key in keys {
1662 if !seen.insert(key) {
1663 continue;
1664 }
1665 match (registry.get(key), urls.get(key)) {
1666 (Some(asset), Some(path)) => out.push(PageAsset {
1667 url: format!("{base}/assets/{path}"),
1668 css: asset.mime.contains("css"),
1669 }),
1670 _ => debug_assert!(false, "asset key `{key}` is not registered"),
1671 }
1672 }
1673 out
1674}
1675
1676pub(crate) fn builtin_assets() -> AssetRegistry {
1685 let font = |bytes: &'static [u8]| AdminAsset {
1686 mime: "font/woff2",
1687 bytes: std::borrow::Cow::Borrowed(bytes),
1688 };
1689 HashMap::from([
1690 (
1691 "laterite.css",
1692 AdminAsset {
1693 mime: "text/css; charset=utf-8",
1694 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
1695 },
1696 ),
1697 (
1698 "laterite.js",
1699 AdminAsset {
1700 mime: "text/javascript; charset=utf-8",
1701 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
1702 },
1703 ),
1704 (
1705 "vendor/htmx.min.js",
1706 AdminAsset {
1707 mime: "text/javascript; charset=utf-8",
1708 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
1709 },
1710 ),
1711 (
1712 "fields/ref-picker.js",
1713 AdminAsset {
1714 mime: "text/javascript; charset=utf-8",
1715 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
1716 },
1717 ),
1718 (
1719 "fields/ref-picker.css",
1720 AdminAsset {
1721 mime: "text/css; charset=utf-8",
1722 bytes: std::borrow::Cow::Borrowed(include_bytes!(
1723 "../assets/fields/ref-picker.css"
1724 )),
1725 },
1726 ),
1727 (
1728 "mark.svg",
1729 AdminAsset {
1730 mime: "image/svg+xml",
1731 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
1732 },
1733 ),
1734 (
1735 "mark.png",
1736 AdminAsset {
1737 mime: "image/png",
1738 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
1739 },
1740 ),
1741 (
1742 "fonts/space-grotesk-500.woff2",
1743 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1744 ),
1745 (
1746 "fonts/space-grotesk-600.woff2",
1747 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1748 ),
1749 (
1750 "fonts/space-grotesk-700.woff2",
1751 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1752 ),
1753 (
1754 "fonts/ibm-plex-sans-400.woff2",
1755 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1756 ),
1757 (
1758 "fonts/ibm-plex-sans-600.woff2",
1759 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1760 ),
1761 (
1762 "fonts/ibm-plex-mono-400.woff2",
1763 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1764 ),
1765 (
1766 "fonts/ibm-plex-mono-600.woff2",
1767 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1768 ),
1769 ])
1770}
1771
1772async fn serve_asset(
1774 State(state): State<AdminState>,
1775 headers: HeaderMap,
1776 Path(path): Path<String>,
1777) -> Response {
1778 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
1782 let Some(asset) = state.assets.get(key.as_str()) else {
1783 return not_found();
1784 };
1785 let policy = if fingerprinted {
1786 http_cache::IMMUTABLE
1787 } else {
1788 http_cache::REVALIDATE
1789 };
1790 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
1791}
1792
1793fn mount_resource(
1798 resource: &Resource,
1799 field_types: &field::FieldRegistry,
1800 column_types: &list::ColumnRegistry,
1801 permission_codes: &std::collections::HashSet<String>,
1802 persisters: &persist::PersisterRegistry,
1803 listeners: &[laterite_core::ModelListenerReg],
1804) -> Router<AdminState> {
1805 list::check(&resource.list, column_types, permission_codes)
1809 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
1810 if resource.form.is_some() && resource.permission.is_none() {
1815 panic!(
1816 "admin resource `{}` has a create/edit form but no permission; a write \
1817 resource must declare a permission",
1818 resource.base_path
1819 );
1820 }
1821 let base = resource.base_path.clone();
1822 let list_path = base.clone();
1823 let list_cfg = resource.list.clone();
1824 let mut router = Router::new().route(
1825 &base,
1826 get(
1827 move |State(state): State<AdminState>,
1828 Extension(shell): Extension<Shell>,
1829 Query(params): Query<list::ListParams>,
1830 Query(raw): Query<std::collections::HashMap<String, String>>,
1831 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
1832 Extension(user): Extension<AuthenticatedUser>,
1833 Extension(session): Extension<session::SessionHandle>,
1834 headers: axum::http::HeaderMap| {
1835 let cfg = list_cfg.clone();
1836 let path = list_path.clone();
1837 async move {
1838 list::handle_remembering(
1839 &state,
1840 &cfg,
1841 &path,
1842 params,
1843 &raw,
1844 &user,
1845 shell,
1846 &headers,
1847 query_string.as_deref(),
1848 Some(&session),
1849 )
1850 .await
1851 }
1852 },
1853 ),
1854 );
1855
1856 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
1859 if resource.list.exportable {
1860 router = router.route(
1861 &format!("{base}/export"),
1862 get(
1863 move |state: State<AdminState>,
1864 Query(raw): Query<std::collections::HashMap<String, String>>,
1865 Extension(user): Extension<AuthenticatedUser>,
1866 Extension(shell): Extension<Shell>,
1867 Extension(session): Extension<session::SessionHandle>| {
1868 let cfg = export_cfg.clone();
1869 let path = export_path.clone();
1870 async move {
1871 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
1872 }
1873 },
1874 ),
1875 );
1876 }
1877
1878 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
1879 router = router.route(
1880 &format!("{base}/columns"),
1881 post(
1882 move |State(state): State<AdminState>,
1883 Extension(user): Extension<AuthenticatedUser>,
1884 Extension(session): Extension<session::SessionHandle>,
1885 headers: axum::http::HeaderMap,
1886 Form(pairs): Form<Vec<(String, String)>>| {
1887 let cfg = columns_cfg.clone();
1888 let path = columns_path.clone();
1889 async move {
1890 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
1891 }
1892 },
1893 ),
1894 );
1895
1896 if resource.list.deletable {
1897 let persister: Arc<dyn persist::Persister> =
1901 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
1902 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
1903 panic!(
1904 "admin resource `{}`: unregistered persister `{key}`",
1905 resource.base_path
1906 )
1907 }),
1908 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
1909 };
1910 let ctx = Arc::new(bulk::BulkContext {
1911 base_path: resource.base_path.clone(),
1912 entity: resource.list.entity.clone(),
1913 persister,
1914 listeners: listeners
1915 .iter()
1916 .filter(|reg| reg.matches(&resource.list.entity))
1917 .map(|reg| reg.listener.clone())
1918 .collect(),
1919 });
1920 router =
1921 router.route(
1922 &format!("{base}/delete"),
1923 post(
1924 move |state: State<AdminState>,
1925 user: Extension<AuthenticatedUser>,
1926 session: Extension<session::SessionHandle>,
1927 headers: axum::http::HeaderMap,
1928 form: Form<Vec<(String, String)>>| {
1929 let ctx = ctx.clone();
1930 async move {
1931 bulk::delete_handler(state, user, session, ctx, headers, form).await
1932 }
1933 },
1934 ),
1935 );
1936 }
1937
1938 if let Some(form_cfg) = resource.form.clone() {
1939 let prepared = Arc::new(
1942 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
1943 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1944 );
1945 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1946 router = router.route(
1947 &format!("{base}/new"),
1948 get(
1949 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1950 let pf = new_pf.clone();
1951 async move { form::new_form(&state, &pf, shell) }
1952 },
1953 )
1954 .post(
1955 move |State(state): State<AdminState>,
1956 Extension(shell): Extension<Shell>,
1957 Extension(user): Extension<AuthenticatedUser>,
1958 Extension(session): Extension<session::SessionHandle>,
1959 headers: axum::http::HeaderMap,
1960 Form(data): Form<HashMap<String, String>>| {
1961 let pf = create_pf.clone();
1962 async move {
1963 form::create(&state, &pf, data, shell, &user, &session, &headers).await
1964 }
1965 },
1966 ),
1967 );
1968
1969 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1970 router = router.route(
1971 &format!("{base}/{{id}}/edit"),
1972 get(
1973 move |State(state): State<AdminState>,
1974 Extension(shell): Extension<Shell>,
1975 Path(id): Path<String>| {
1976 let pf = edit_pf.clone();
1977 async move { form::edit_form(&state, &pf, id, shell).await }
1978 },
1979 )
1980 .post(
1981 move |State(state): State<AdminState>,
1982 Extension(shell): Extension<Shell>,
1983 Extension(user): Extension<AuthenticatedUser>,
1984 Extension(session): Extension<session::SessionHandle>,
1985 Path(id): Path<String>,
1986 headers: axum::http::HeaderMap,
1987 Form(data): Form<HashMap<String, String>>| {
1988 let pf = update_pf.clone();
1989 async move {
1990 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
1991 }
1992 },
1993 ),
1994 );
1995 }
1996
1997 if let Some(permission) = &resource.permission {
1999 router = guard_with_permission(router, permission);
2000 }
2001 router
2002}
2003
2004fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
2009 let needed: Arc<str> = Arc::from(permission);
2010 router.route_layer(middleware::from_fn(
2011 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
2012 let needed = needed.clone();
2013 async move {
2014 if user.allows(&needed) {
2015 next.run(req).await
2016 } else {
2017 forbidden()
2018 }
2019 }
2020 },
2021 ))
2022}
2023
2024async fn require_auth(
2030 State(state): State<AdminState>,
2031 jar: CookieJar,
2032 request: Request,
2033 next: Next,
2034) -> Response {
2035 let login = format!("{}/login", state.admin_path);
2036 let client_ctx = request
2037 .extensions()
2038 .get::<RequestContext>()
2039 .cloned()
2040 .unwrap_or_default();
2041 let mut ended: Option<laterite_auth::SessionEnd> = None;
2046 let live = match jar.get(SESSION_COOKIE) {
2047 Some(cookie) => {
2048 let token = cookie.value().to_string();
2049 match state.auth.resolve_session(&token).await {
2050 Ok(resolved) => Some((token, resolved)),
2051 Err(_) => {
2052 ended = state.auth.session_end(&token).await.ok().flatten();
2053 None
2054 }
2055 }
2056 }
2057 None => None,
2058 };
2059 let htmx = request.headers().contains_key("hx-request");
2060 let (token, resolved, recalled) = match live {
2061 Some((token, resolved)) => (token, resolved, None),
2062 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2065 return signed_out(&state, jar, &login, ended, htmx);
2066 }
2067 None => match recall(&state, &jar, &client_ctx).await {
2068 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2069 None => {
2070 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2074 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2075 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2076 }
2077 }
2078 return signed_out(&state, jar, &login, ended, htmx);
2079 }
2080 },
2081 };
2082 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2083
2084 let mut request = request;
2089 let safe = session::is_safe_method(request.method());
2090 let mut deferred: Option<session::CsrfPending> = None;
2092 if !safe {
2093 let (parts, body) = request.into_parts();
2094 let (body, submitted) = if session::is_multipart(&parts.headers) {
2101 let token = session::header_token(&parts.headers)
2102 .or_else(|| session::query_token(parts.uri.query()));
2103 if token.is_none() {
2108 deferred = Some(session::CsrfPending::default());
2109 }
2110 (body, token)
2111 } else {
2112 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2113 .await
2114 .unwrap_or_default();
2115 let token = session::submitted_token(&parts.headers, &bytes);
2116 (Body::from(bytes), token)
2117 };
2118 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2119 {
2120 tracing::warn!(
2121 user_id = resolved.identity.user.id,
2122 "admin CSRF check failed"
2123 );
2124 return error::csrf_rejected();
2125 }
2126 request = Request::from_parts(parts, body);
2127 }
2128
2129 let user = resolved.identity;
2130 let acting_user_id = user.user.id;
2132 let path = request.uri().path().to_string();
2133 let preferences = format!("{}/preferences", state.admin_path);
2136 if !path.starts_with(&preferences)
2137 && path != format!("{}/logout", state.admin_path)
2138 && state
2139 .auth
2140 .must_change_password(user.user.id)
2141 .await
2142 .unwrap_or(false)
2143 {
2144 let to = format!("{preferences}#password");
2145 let response = if htmx {
2146 (
2147 [("HX-Redirect", to.as_str())],
2148 axum::http::StatusCode::NO_CONTENT,
2149 )
2150 .into_response()
2151 } else {
2152 Redirect::to(&to).into_response()
2153 };
2154 return with_recall_cookies(response, recalled, jar, token, &state);
2155 }
2156 let flash = if safe {
2159 handle.take_flash()
2160 } else {
2161 Vec::new()
2162 };
2163 let accept_language = request
2167 .headers()
2168 .get("accept-language")
2169 .and_then(|v| v.to_str().ok());
2170 let serveable = state.catalogs.locales();
2171 let chain = resolve_locale_chain(
2172 user.user.locale.as_deref(),
2173 accept_language,
2174 &state.default_locale,
2175 &serveable,
2176 );
2177 let i18n = Translator::with_chain(chain, state.catalogs.clone());
2178 let translator = i18n.clone();
2181 let (sidebar, active_nav) = resolve_nav_context(
2182 &state.nav,
2183 &state.settings,
2184 &state.admin_path,
2185 &user.permissions,
2186 &path,
2187 &i18n,
2188 icons::Icons::new(&state.icons, &state.sprite_url),
2189 );
2190 let brand = state.brand().await;
2191 let shell = Shell::new(
2192 &state.admin_path,
2193 brand,
2194 &state.nav,
2195 &user,
2196 state.timezone,
2197 sidebar,
2198 active_nav.as_deref(),
2199 handle.csrf_token(),
2200 flash,
2201 i18n,
2202 state.asset_urls.clone(),
2203 state.origin.to_string(),
2204 state.icons.clone(),
2205 state.sprite_url.clone(),
2206 );
2207 request.extensions_mut().insert(user);
2208 request.extensions_mut().insert(shell);
2209 request.extensions_mut().insert(handle.clone());
2210 if let Some(pending) = &deferred {
2212 request.extensions_mut().insert(pending.clone());
2213 }
2214 let mut response = next.run(request).await;
2215
2216 if let Some(pending) = &deferred {
2221 if !pending.was_satisfied() {
2222 tracing::error!(
2223 user_id = acting_user_id,
2224 "a multipart route returned without verifying its request token"
2225 );
2226 return error::csrf_rejected();
2227 }
2228 }
2229
2230 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2234 response = error::localized_error(meta.kind, &translator);
2235 }
2236
2237 if let Some(blob) = handle.dirty_blob() {
2240 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2241 tracing::error!(error = %e, "persisting admin session failed");
2242 }
2243 }
2244 with_recall_cookies(response, recalled, jar, token, &state)
2245}
2246
2247fn with_recall_cookies(
2251 response: Response,
2252 recalled: Option<laterite_auth::RememberCredential>,
2253 jar: CookieJar,
2254 token: String,
2255 state: &AdminState,
2256) -> Response {
2257 match recalled {
2258 Some(credential) => {
2259 let jar = jar
2260 .add(session_cookie(
2261 token,
2262 &state.admin_path,
2263 state.secure_cookie,
2264 ))
2265 .add(remember_cookie(
2266 credential,
2267 &state.admin_path,
2268 state.secure_cookie,
2269 ));
2270 (jar, response).into_response()
2271 }
2272 None => response,
2273 }
2274}
2275
2276async fn recall(
2280 state: &AdminState,
2281 jar: &CookieJar,
2282 ctx: &RequestContext,
2283) -> Option<(
2284 String,
2285 laterite_auth::ResolvedSession,
2286 laterite_auth::RememberCredential,
2287)> {
2288 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
2289 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
2290 let token = recalled.session.token.clone();
2291 let resolved = state.auth.resolve_session(&token).await.ok()?;
2292 Some((token, resolved, recalled.remember))
2293}
2294
2295const MAX_FORM_BYTES: usize = 1024 * 1024;
2298
2299async fn capture_client(
2307 State(state): State<AdminState>,
2308 mut request: Request,
2309 next: Next,
2310) -> Response {
2311 let peer = request
2312 .extensions()
2313 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
2314 .map(|info| info.0);
2315 let headers = request.headers();
2316 let ctx = RequestContext {
2317 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
2318 user_agent: clientip::user_agent(headers),
2319 };
2320 request.extensions_mut().insert(ctx);
2321 next.run(request).await
2322}
2323
2324async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
2325 if !session::is_safe_method(request.method())
2326 && !session::origin_ok(request.headers(), &state.origin)
2327 {
2328 tracing::warn!(
2329 path = request.uri().path(),
2330 "admin CSRF origin check failed"
2331 );
2332 return error::csrf_rejected();
2333 }
2334 next.run(request).await
2335}
2336
2337#[derive(Deserialize)]
2338struct LoginQuery {
2339 ended: Option<String>,
2340}
2341
2342async fn login_form(
2343 State(state): State<AdminState>,
2344 headers: HeaderMap,
2345 Query(query): Query<LoginQuery>,
2346) -> Response {
2347 match state.auth.has_any_operator().await {
2349 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
2350 Ok(true) => {
2351 let i18n = pre_auth_translator(&state, &headers);
2352 let notice = query
2353 .ended
2354 .as_deref()
2355 .and_then(end_notice)
2356 .map(|text| i18n.t(&text));
2357 render(LoginTemplate {
2358 base: state.admin_path.to_string(),
2359 asset_urls: state.asset_urls.clone(),
2360 brand: state.brand().await,
2361 error: None,
2362 notice,
2363 i18n,
2364 })
2365 }
2366 Err(_) => render_error(),
2367 }
2368}
2369
2370#[derive(Deserialize)]
2371struct LoginForm {
2372 username: String,
2373 password: String,
2374 #[serde(default)]
2376 remember: Option<String>,
2377}
2378
2379async fn login_submit(
2380 State(state): State<AdminState>,
2381 jar: CookieJar,
2382 headers: HeaderMap,
2383 Extension(ctx): Extension<RequestContext>,
2384 Form(form): Form<LoginForm>,
2385) -> Response {
2386 match state
2387 .auth
2388 .authenticate(&form.username, &form.password, &ctx)
2389 .await
2390 {
2391 Ok(session) => {
2392 let wants_remember = form.remember.is_some();
2393 let user_id = session.user_id;
2394 let mut jar = jar.add(session_cookie(
2395 session.token,
2396 &state.admin_path,
2397 state.secure_cookie,
2398 ));
2399 if wants_remember {
2400 match state.auth.issue_remember(user_id).await {
2401 Ok(credential) => {
2402 jar = jar.add(remember_cookie(
2403 credential,
2404 &state.admin_path,
2405 state.secure_cookie,
2406 ));
2407 }
2408 Err(e) => {
2410 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
2411 }
2412 }
2413 }
2414 (jar, Redirect::to(&state.admin_path)).into_response()
2415 }
2416 Err(_) => {
2417 let i18n = pre_auth_translator(&state, &headers);
2418 render(LoginTemplate {
2419 base: state.admin_path.to_string(),
2420 asset_urls: state.asset_urls.clone(),
2421 brand: state.brand().await,
2422 error: Some(i18n.t(&t!("Invalid username or password."))),
2423 notice: None,
2424 i18n,
2425 })
2426 }
2427 }
2428}
2429
2430fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
2439 Cookie::build((SESSION_COOKIE, token))
2440 .path(admin_path.to_string())
2441 .http_only(true)
2442 .secure(secure)
2443 .same_site(SameSite::Lax)
2444 .build()
2445}
2446
2447fn remember_cookie(
2450 credential: laterite_auth::RememberCredential,
2451 admin_path: &str,
2452 secure: bool,
2453) -> Cookie<'static> {
2454 let seconds = (credential.expires_at - chrono::Utc::now())
2455 .num_seconds()
2456 .max(0);
2457 Cookie::build((REMEMBER_COOKIE, credential.cookie))
2458 .path(admin_path.to_string())
2459 .http_only(true)
2460 .secure(secure)
2461 .same_site(SameSite::Lax)
2462 .max_age(cookie::time::Duration::seconds(seconds))
2463 .build()
2464}
2465
2466fn signed_out(
2473 state: &AdminState,
2474 jar: CookieJar,
2475 login: &str,
2476 ended: Option<laterite_auth::SessionEnd>,
2477 htmx: bool,
2478) -> Response {
2479 let to = match ended.map(laterite_auth::SessionEnd::code) {
2480 Some(code) => format!("{login}?ended={code}"),
2481 None => login.to_string(),
2482 };
2483 let jar = jar
2484 .remove(
2485 Cookie::build((SESSION_COOKIE, ""))
2486 .path(state.admin_path.to_string())
2487 .build(),
2488 )
2489 .remove(remember_removal(&state.admin_path));
2490 if htmx {
2491 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
2492 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
2493 response.headers_mut().insert("hx-redirect", value);
2494 }
2495 return response;
2496 }
2497 (jar, Redirect::to(&to)).into_response()
2498}
2499
2500fn end_notice(code: &str) -> Option<Text> {
2503 Some(match code {
2504 "password_changed" => {
2505 t!("Your password was changed, so you were signed out. Sign in with the new one.")
2506 }
2507 "deactivated" => t!("This account was deactivated."),
2508 "signed_out_elsewhere" => t!("You were signed out from another device."),
2509 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
2510 _ => return None,
2511 })
2512}
2513
2514fn remember_removal(admin_path: &str) -> Cookie<'static> {
2516 Cookie::build((REMEMBER_COOKIE, ""))
2517 .path(admin_path.to_string())
2518 .build()
2519}
2520
2521#[derive(Deserialize)]
2522struct SetupForm {
2523 username: String,
2524 first_name: String,
2525 last_name: String,
2526 email: String,
2527 password: String,
2528 timezone: String,
2529}
2530
2531async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2534 match state.auth.has_any_operator().await {
2535 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2536 Ok(false) => render(setup_view(
2537 &state.admin_path,
2538 state.brand().await,
2539 state.timezone,
2540 None,
2541 pre_auth_translator(&state, &headers),
2542 state.asset_urls.clone(),
2543 state.auth.password_policy().min_length,
2544 )),
2545 Err(_) => render_error(),
2546 }
2547}
2548
2549async fn setup_submit(
2550 State(state): State<AdminState>,
2551 jar: CookieJar,
2552 headers: HeaderMap,
2553 Form(form): Form<SetupForm>,
2554) -> Response {
2555 match state.auth.has_any_operator().await {
2557 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2558 Ok(false) => {}
2559 Err(_) => return render_error(),
2560 }
2561
2562 let username = form.username.trim();
2563 let email = form.email.trim();
2564 let first_name = form.first_name.trim();
2565 let last_name = form.last_name.trim();
2566 let tz = form.timezone.trim();
2567 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
2568 {
2569 return render(setup_view(
2570 &state.admin_path,
2571 state.brand().await,
2572 state.timezone,
2573 Some(t!(
2574 "Username, first name, email, and password are all required."
2575 )),
2576 pre_auth_translator(&state, &headers),
2577 state.asset_urls.clone(),
2578 state.auth.password_policy().min_length,
2579 ));
2580 }
2581 if tz.parse::<Tz>().is_err() {
2583 return render(setup_view(
2584 &state.admin_path,
2585 state.brand().await,
2586 state.timezone,
2587 Some(t!("That is not a recognised timezone.")),
2588 pre_auth_translator(&state, &headers),
2589 state.asset_urls.clone(),
2590 state.auth.password_policy().min_length,
2591 ));
2592 }
2593
2594 let min_length = state.auth.password_policy().min_length;
2595 if form.password.chars().count() < min_length {
2596 return render(setup_view(
2597 &state.admin_path,
2598 state.brand().await,
2599 state.timezone,
2600 Some(t!(
2601 "A password needs at least {n} characters.",
2602 n = min_length as i64
2603 )),
2604 pre_auth_translator(&state, &headers),
2605 state.asset_urls.clone(),
2606 state.auth.password_policy().min_length,
2607 ));
2608 }
2609 let new = NewOperator {
2610 username,
2611 email,
2612 first_name,
2613 last_name: (!last_name.is_empty()).then_some(last_name),
2614 password: &form.password,
2615 timezone: Some(tz),
2616 };
2617 let id = match state.auth.create_superuser(new).await {
2618 Ok(id) => id,
2619 Err(_) => {
2620 return render(setup_view(
2621 &state.admin_path,
2622 state.brand().await,
2623 state.timezone,
2624 Some(t!(
2625 "Could not create the account. The username or email may already be taken."
2626 )),
2627 pre_auth_translator(&state, &headers),
2628 state.asset_urls.clone(),
2629 state.auth.password_policy().min_length,
2630 ));
2631 }
2632 };
2633 let actor = laterite_core::Actor::system("first-run setup");
2636 let target = id.to_string();
2637 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
2638 "backend_user",
2639 &target,
2640 Some(username),
2641 );
2642 if let Err(e) = state.auth.record_audit(entry).await {
2643 tracing::error!(error = %e, "failed to write audit log entry");
2644 }
2645
2646 match state
2648 .auth
2649 .authenticate(username, &form.password, &RequestContext::default())
2650 .await
2651 {
2652 Ok(session) => {
2653 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
2654 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
2655 }
2656 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2657 }
2658}
2659
2660fn setup_view(
2663 admin_path: &str,
2664 brand: String,
2665 default_tz: Tz,
2666 error: Option<Text>,
2667 i18n: Translator,
2668 asset_urls: Arc<AssetUrls>,
2669 min_password: usize,
2670) -> SetupTemplate {
2671 let default_name = default_tz.name();
2672 let zones = TZ_VARIANTS
2673 .iter()
2674 .filter(|tz| zone_offered(tz.name()))
2675 .map(|tz| TzOption {
2676 name: tz.name().to_string(),
2677 selected: tz.name() == default_name,
2678 })
2679 .collect();
2680 SetupTemplate {
2681 base: admin_path.to_string(),
2682 asset_urls,
2683 brand,
2684 zones,
2685 error: error.map(|e| i18n.t(&e)),
2686 min_password: min_password as i64,
2687 i18n,
2688 }
2689}
2690
2691async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
2692 if let Some(cookie) = jar.get(SESSION_COOKIE) {
2693 let _ = state.auth.logout(cookie.value()).await;
2694 }
2695 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2698 let _ = state.auth.revoke_remember(cookie.value()).await;
2699 }
2700 let removal = Cookie::build((SESSION_COOKIE, ""))
2701 .path(state.admin_path.to_string())
2702 .build();
2703 (
2704 jar.remove(removal)
2705 .remove(remember_removal(&state.admin_path)),
2706 Redirect::to(&format!("{}/login", state.admin_path)),
2707 )
2708 .into_response()
2709}
2710
2711async fn dashboard(
2712 Extension(shell): Extension<Shell>,
2713 Extension(user): Extension<AuthenticatedUser>,
2714) -> Response {
2715 render(DashboardTemplate {
2716 username: user.user.username,
2717 shell,
2718 })
2719}
2720
2721fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
2724 match &item.permission {
2725 None => true,
2726 Some(p) => perms.allows(p),
2727 }
2728}
2729
2730fn visible_settings(
2734 items: &[settings::SettingsItem],
2735 perms: &PermissionSet,
2736) -> Vec<settings::SettingsItem> {
2737 items
2738 .iter()
2739 .filter(|item| operator_can_see(item, perms))
2740 .cloned()
2741 .collect()
2742}
2743
2744async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
2745 settings::index(shell)
2746}
2747
2748async fn settings_edit(
2749 state: AdminState,
2750 shell: Shell,
2751 user: AuthenticatedUser,
2752 code: String,
2753) -> Response {
2754 let items = visible_settings(&state.settings, &user.permissions);
2757 match items
2758 .iter()
2759 .find(|item| item.code == code && item.link.is_none())
2760 {
2761 Some(item) => settings::edit_form(&state, item, shell).await,
2762 None => not_found(),
2763 }
2764}
2765
2766async fn settings_update(
2767 state: AdminState,
2768 shell: Shell,
2769 user: AuthenticatedUser,
2770 session: session::SessionHandle,
2771 code: String,
2772 data: HashMap<String, String>,
2773) -> Response {
2774 let items = visible_settings(&state.settings, &user.permissions);
2775 match items
2776 .iter()
2777 .find(|item| item.code == code && item.link.is_none())
2778 {
2779 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
2780 None => not_found(),
2781 }
2782}
2783
2784async fn preferences_form(
2786 State(state): State<AdminState>,
2787 Extension(shell): Extension<Shell>,
2788 Extension(user): Extension<AuthenticatedUser>,
2789 jar: CookieJar,
2790) -> Response {
2791 let account = account_security(&state, &user, &shell, &jar).await;
2792 render(preferences_view(
2793 &shell,
2794 &user,
2795 state.timezone,
2796 &state.default_locale,
2797 &offered_locales(&state.catalogs),
2798 account,
2799 state.auth.password_policy().min_length,
2800 ))
2801}
2802
2803#[derive(Deserialize)]
2804struct RevokeSessionForm {
2805 id: String,
2806}
2807
2808async fn session_revoke(
2810 State(state): State<AdminState>,
2811 Extension(user): Extension<AuthenticatedUser>,
2812 Extension(session): Extension<session::SessionHandle>,
2813 Form(form): Form<RevokeSessionForm>,
2814) -> Response {
2815 match state.auth.revoke_session(user.user.id, &form.id).await {
2816 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
2817 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
2818 Err(e) => {
2819 tracing::error!(error = %e, "revoking a session failed");
2820 session.push_flash(
2821 FlashLevel::Error,
2822 t!("That session could not be signed out."),
2823 );
2824 }
2825 }
2826 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2827}
2828
2829async fn session_revoke_others(
2831 State(state): State<AdminState>,
2832 Extension(user): Extension<AuthenticatedUser>,
2833 Extension(session): Extension<session::SessionHandle>,
2834 jar: CookieJar,
2835) -> Response {
2836 let token = jar
2837 .get(SESSION_COOKIE)
2838 .map(|c| c.value().to_string())
2839 .unwrap_or_default();
2840 match state.auth.sign_out_everywhere(user.user.id, &token).await {
2841 Ok(_) => session.push_flash(
2842 FlashLevel::Success,
2843 t!("Every other session was signed out."),
2844 ),
2845 Err(e) => {
2846 tracing::error!(error = %e, "signing out other sessions failed");
2847 session.push_flash(
2848 FlashLevel::Error,
2849 t!("Those sessions could not be signed out."),
2850 );
2851 }
2852 }
2853 (
2856 jar.remove(remember_removal(&state.admin_path)),
2857 Redirect::to(&format!("{}/preferences", state.admin_path)),
2858 )
2859 .into_response()
2860}
2861
2862#[derive(Deserialize)]
2863struct PasswordForm {
2864 current_password: String,
2865 new_password: String,
2866 confirm_password: String,
2867}
2868
2869async fn password_update(
2874 State(state): State<AdminState>,
2875 Extension(user): Extension<AuthenticatedUser>,
2876 Extension(session): Extension<session::SessionHandle>,
2877 Extension(ctx): Extension<RequestContext>,
2878 jar: CookieJar,
2879 Form(form): Form<PasswordForm>,
2880) -> Response {
2881 let back = format!("{}/preferences#password", state.admin_path);
2882 let refuse = |message: Text| {
2883 session.push_flash(FlashLevel::Error, message);
2884 Redirect::to(&back).into_response()
2885 };
2886 if form.new_password != form.confirm_password {
2887 return refuse(t!("The new passwords do not match."));
2888 }
2889 let min_length = state.auth.password_policy().min_length;
2890 if form.new_password.chars().count() < min_length {
2891 return refuse(t!(
2892 "A password needs at least {n} characters.",
2893 n = min_length as i64
2894 ));
2895 }
2896 let remembered_here = match jar.get(REMEMBER_COOKIE) {
2899 Some(cookie) => {
2900 state
2901 .auth
2902 .remember_holder(cookie.value())
2903 .await
2904 .ok()
2905 .flatten()
2906 == Some(user.user.id)
2907 }
2908 None => false,
2909 };
2910 let token = jar
2911 .get(SESSION_COOKIE)
2912 .map(|c| c.value().to_string())
2913 .unwrap_or_default();
2914 let changed = state
2915 .auth
2916 .change_own_password(
2917 user.user.id,
2918 &form.current_password,
2919 &form.new_password,
2920 &token,
2921 &ctx,
2922 )
2923 .await;
2924 match changed {
2925 Ok(()) => {}
2926 Err(laterite_auth::AuthError::InvalidCredentials) => {
2927 return refuse(t!("Your current password is not correct."));
2928 }
2929 Err(laterite_auth::AuthError::TooManyAttempts) => {
2930 return refuse(t!("Too many attempts. Try again later."));
2931 }
2932 Err(e) => {
2933 tracing::error!(error = %e, "changing a password failed");
2934 return refuse(t!("The password could not be changed."));
2935 }
2936 }
2937 session.push_flash_sticky(
2938 FlashLevel::Success,
2939 t!("Password changed. Every other device was signed out."),
2940 );
2941 let jar = if remembered_here {
2942 match state.auth.issue_remember(user.user.id).await {
2943 Ok(credential) => jar.add(remember_cookie(
2944 credential,
2945 &state.admin_path,
2946 state.secure_cookie,
2947 )),
2948 Err(e) => {
2949 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
2950 jar.remove(remember_removal(&state.admin_path))
2951 }
2952 }
2953 } else if jar.get(REMEMBER_COOKIE).is_some() {
2954 jar.remove(remember_removal(&state.admin_path))
2957 } else {
2958 jar
2959 };
2960 (
2961 jar,
2962 Redirect::to(&format!("{}/preferences", state.admin_path)),
2963 )
2964 .into_response()
2965}
2966
2967#[derive(Deserialize)]
2968struct PreferencesForm {
2969 timezone: String,
2970 #[serde(default)]
2972 locale: String,
2973}
2974
2975async fn preferences_update(
2976 State(state): State<AdminState>,
2977 Extension(user): Extension<AuthenticatedUser>,
2978 Extension(session): Extension<session::SessionHandle>,
2979 Form(form): Form<PreferencesForm>,
2980) -> Response {
2981 let back = format!("{}/preferences", state.admin_path);
2982 let offered = offered_locales(&state.catalogs);
2983 let tz = form.timezone.trim();
2985 let tz_stored = if tz.is_empty() {
2986 None
2987 } else if tz.parse::<Tz>().is_ok() {
2988 Some(tz)
2989 } else {
2990 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
2991 return Redirect::to(&back).into_response();
2992 };
2993 let loc = form.locale.trim();
2994 let loc_stored = if loc.is_empty() {
2995 None
2996 } else if offered.iter().any(|l| l == loc) {
2997 Some(loc)
2998 } else {
2999 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
3000 return Redirect::to(&back).into_response();
3001 };
3002 if state
3003 .auth
3004 .set_user_timezone(user.user.id, tz_stored)
3005 .await
3006 .is_err()
3007 {
3008 return render_error();
3009 }
3010 match state.auth.set_user_locale(user.user.id, loc_stored).await {
3011 Ok(()) => {
3012 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
3013 Redirect::to(&back).into_response()
3014 }
3015 Err(_) => render_error(),
3016 }
3017}
3018
3019fn preferences_view(
3023 shell: &Shell,
3024 user: &AuthenticatedUser,
3025 default_tz: Tz,
3026 default_locale: &str,
3027 offered: &[String],
3028 account: AccountSecurity,
3029 min_password: usize,
3030) -> PreferencesTemplate {
3031 let current = user.user.timezone.as_deref();
3032 let zones = TZ_VARIANTS
3033 .iter()
3034 .filter(|tz| zone_offered(tz.name()))
3035 .map(|tz| TzOption {
3036 name: tz.name().to_string(),
3037 selected: current == Some(tz.name()),
3038 })
3039 .collect();
3040 let current_locale = user.user.locale.as_deref();
3041 let locales = offered
3042 .iter()
3043 .map(|code| LocaleOption {
3044 name: locale_name(code),
3045 selected: current_locale == Some(code.as_str()),
3046 code: code.clone(),
3047 })
3048 .collect();
3049 PreferencesTemplate {
3050 shell: shell.clone(),
3051 zones,
3052 effective_tz: shell.tz.name().to_string(),
3053 default_tz: default_tz.name().to_string(),
3054 inherits: current.is_none(),
3055 locales,
3056 default_locale: locale_name(default_locale),
3057 inherits_locale: current_locale.is_none(),
3058 sessions: account.sessions,
3059 password_changed: account.password_changed,
3060 must_change: account.must_change,
3061 min_password: min_password as i64,
3062 }
3063}
3064
3065struct AccountSecurity {
3068 sessions: Vec<SessionRow>,
3069 password_changed: Option<String>,
3070 must_change: bool,
3072}
3073
3074async fn account_security(
3075 state: &AdminState,
3076 user: &AuthenticatedUser,
3077 shell: &Shell,
3078 jar: &CookieJar,
3079) -> AccountSecurity {
3080 let locale = list::date_locale(shell.locale());
3081 let password_changed = match state.auth.password_changed_at(user.user.id).await {
3082 Ok(at) => {
3083 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
3084 }
3085 Err(e) => {
3086 tracing::error!(error = %e, "reading the password change time failed");
3087 None
3088 }
3089 };
3090 AccountSecurity {
3091 sessions: session_rows(state, user, shell, jar).await,
3092 password_changed,
3093 must_change: state
3094 .auth
3095 .must_change_password(user.user.id)
3096 .await
3097 .unwrap_or(false),
3098 }
3099}
3100
3101async fn session_rows(
3104 state: &AdminState,
3105 user: &AuthenticatedUser,
3106 shell: &Shell,
3107 jar: &CookieJar,
3108) -> Vec<SessionRow> {
3109 let token = jar
3110 .get(SESSION_COOKIE)
3111 .map(|c| c.value().to_string())
3112 .unwrap_or_default();
3113 let locale = list::date_locale(shell.locale());
3114 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
3115 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
3116 };
3117 match state.auth.list_sessions(user.user.id, &token).await {
3118 Ok(sessions) => sessions
3119 .into_iter()
3120 .map(|s| SessionRow {
3121 id: s.id,
3122 started: stamp(s.created_at),
3123 last_seen: stamp(s.last_seen_at),
3124 expires: stamp(s.expires_at),
3125 device: clientip::describe(s.user_agent.as_deref())
3126 .or(s.user_agent)
3127 .unwrap_or_else(|| "-".to_string()),
3128 ip_address: s.ip_address.unwrap_or_default(),
3129 current: s.current,
3130 })
3131 .collect(),
3132 Err(e) => {
3133 tracing::error!(error = %e, "listing sessions failed");
3134 Vec::new()
3135 }
3136 }
3137}
3138
3139fn builtin_resources() -> Vec<Resource> {
3141 vec![
3142 Resource {
3143 base_path: "/users".to_string(),
3144 nav_label: "Backend Users".into(),
3145 list: backend_users_list_config(),
3146 form: None,
3147 permission: Some("backend.manage_users".to_string()),
3148 },
3149 Resource {
3150 base_path: "/roles".to_string(),
3151 nav_label: "Roles".into(),
3152 list: roles_list_config(),
3153 form: None,
3156 permission: Some("backend.manage_roles".to_string()),
3157 },
3158 resource!("descriptors/audit-log.yaml"),
3159 resource!("descriptors/access-log.yaml"),
3160 ]
3161}
3162
3163fn builtin_settings() -> Vec<settings::SettingsItem> {
3167 vec![
3168 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
3169 .description("Manage backend administrator accounts.")
3170 .category("Users")
3171 .order(10)
3172 .icon("users")
3173 .permission("backend.manage_users")
3174 .link("/users"),
3175 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
3176 .description("Manage roles and their permissions.")
3177 .category("Users")
3178 .order(20)
3179 .icon("shield")
3180 .permission("backend.manage_roles")
3181 .link("/roles"),
3182 settings::brand::settings_item(),
3183 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
3184 .description("Enable or disable installed plugins.")
3185 .category("System")
3186 .order(10)
3187 .icon("plug")
3188 .permission(plugins::MANAGE_PERMISSION)
3189 .link("/plugins"),
3190 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
3191 .description("Review the record of administrative changes.")
3192 .category("System")
3193 .order(20)
3194 .icon("history")
3195 .permission("backend.view_audit_log")
3196 .link("/audit-log"),
3197 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
3198 .description("Review sign-ins, failures and lockouts.")
3199 .category("System")
3200 .order(30)
3201 .icon("log-in")
3202 .permission("backend.view_access_log")
3203 .link("/access-log"),
3204 ]
3205}
3206
3207fn backend_users_list_config() -> list::ListConfig {
3208 list::ListConfig {
3209 entity: "backend_users".to_string(),
3210 title: "Backend Users".into(),
3211 columns: vec![
3212 list::ListColumn::new("username", "Username"),
3213 list::ListColumn::new("email", "Email"),
3214 list::ListColumn::new("first_name", "First name"),
3215 list::ListColumn::new("last_name", "Last name"),
3216 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
3217 list::ListColumn::new("is_active", "Active").yes_no(),
3218 list::ListColumn::new("created_at", "Created").datetime(),
3219 ],
3220 order_by: "created_at".to_string(),
3221 creatable: true,
3224 edit_base: Some("/users".to_string()),
3225 filters: vec![
3226 list::ListFilter::boolean("is_active", "Active"),
3227 list::ListFilter::boolean("is_superuser", "Superuser"),
3228 ],
3229 ..Default::default()
3233 }
3234}
3235
3236fn roles_list_config() -> list::ListConfig {
3237 list::ListConfig {
3238 entity: "backend_roles".to_string(),
3239 title: "Roles".into(),
3240 columns: vec![
3241 list::ListColumn::new("code", "Code"),
3242 list::ListColumn::new("name", "Name"),
3243 list::ListColumn::new("created_at", "Created").datetime(),
3244 ],
3245 order_by: "created_at".to_string(),
3246 edit_base: Some("/roles".to_string()),
3247 creatable: true,
3248 deletable: true,
3249 ..Default::default()
3250 }
3251}
3252
3253#[derive(Template)]
3254#[template(path = "login.html")]
3255struct LoginTemplate {
3256 base: String,
3258 asset_urls: Arc<AssetUrls>,
3260 brand: String,
3261 error: Option<String>,
3262 notice: Option<String>,
3264 i18n: Translator,
3267}
3268
3269fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
3272 match urls.get(key) {
3273 Some(path) => format!("{base}/assets/{path}"),
3274 None => {
3275 debug_assert!(false, "asset key `{key}` is not registered");
3276 format!("{base}/assets/{key}")
3277 }
3278 }
3279}
3280
3281impl LoginTemplate {
3282 fn asset(&self, key: &str) -> String {
3283 preauth_asset(&self.asset_urls, &self.base, key)
3284 }
3285 fn t(&self, source: &str) -> String {
3286 self.i18n.t(&Text::dynamic(source))
3287 }
3288 fn locale(&self) -> &str {
3289 self.i18n.locale()
3290 }
3291}
3292
3293#[derive(Template)]
3294#[template(path = "dashboard.html")]
3295struct DashboardTemplate {
3296 shell: Shell,
3297 username: String,
3298}
3299
3300#[derive(Template)]
3301#[template(path = "setup.html")]
3302struct SetupTemplate {
3303 base: String,
3305 asset_urls: Arc<AssetUrls>,
3307 brand: String,
3308 zones: Vec<TzOption>,
3309 error: Option<String>,
3310 i18n: Translator,
3312 min_password: i64,
3314}
3315
3316impl SetupTemplate {
3317 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
3320 let mut text = Text::dynamic(source);
3321 for (name, value) in args {
3322 text = text.arg(*name, *value);
3323 }
3324 self.i18n.t(&text)
3325 }
3326
3327 fn asset(&self, key: &str) -> String {
3328 preauth_asset(&self.asset_urls, &self.base, key)
3329 }
3330 fn t(&self, source: &str) -> String {
3331 self.i18n.t(&Text::dynamic(source))
3332 }
3333 fn locale(&self) -> &str {
3334 self.i18n.locale()
3335 }
3336}
3337
3338#[derive(Template)]
3339#[template(path = "preferences.html")]
3340struct PreferencesTemplate {
3341 shell: Shell,
3342 zones: Vec<TzOption>,
3343 effective_tz: String,
3345 default_tz: String,
3347 inherits: bool,
3349 locales: Vec<LocaleOption>,
3350 default_locale: String,
3352 inherits_locale: bool,
3354 sessions: Vec<SessionRow>,
3356 password_changed: Option<String>,
3358 must_change: bool,
3360 min_password: i64,
3362}
3363
3364struct SessionRow {
3366 id: String,
3367 started: String,
3368 last_seen: String,
3369 expires: String,
3370 device: String,
3373 ip_address: String,
3375 current: bool,
3376}
3377
3378struct TzOption {
3379 name: String,
3380 selected: bool,
3381}
3382
3383struct LocaleOption {
3384 code: String,
3386 name: String,
3388 selected: bool,
3389}
3390
3391#[derive(Clone)]
3392struct NavView {
3393 label: String,
3394 path: String,
3395 active: bool,
3396 icon: String,
3399}
3400
3401pub(crate) fn render<T: Template>(template: T) -> Response {
3403 match template.render() {
3404 Ok(html) => Html(html).into_response(),
3405 Err(e) => AdminError::from(e).into_response(),
3407 }
3408}
3409
3410pub(crate) fn render_error() -> Response {
3411 error::masked_500()
3412}
3413
3414pub(crate) fn not_found() -> Response {
3415 AdminError::NotFound.into_response()
3416}
3417
3418pub(crate) fn forbidden() -> Response {
3419 AdminError::Forbidden.into_response()
3420}
3421
3422#[cfg(test)]
3423mod tests {
3424 use super::*;
3425
3426 fn write_form() -> form::FormConfig {
3427 form::FormConfig {
3428 enter: Default::default(),
3429 confirm_leave: true,
3430 focus: Default::default(),
3431 entity: "widgets".to_string(),
3432 title: "Widgets".into(),
3433 base_path: "/widgets".to_string(),
3434 id_field: "id".to_string(),
3435 fields: vec![],
3436 persist: None,
3437 timestamps: false,
3438 }
3439 }
3440
3441 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
3442 Resource {
3443 base_path: "/widgets".to_string(),
3444 nav_label: "Widgets".into(),
3445 list: backend_users_list_config(),
3446 form,
3447 permission: permission.map(str::to_string),
3448 }
3449 }
3450
3451 #[test]
3452 #[should_panic(expected = "must declare a permission")]
3453 fn write_resource_without_permission_aborts_boot() {
3454 let resource = resource_with(Some(write_form()), None);
3457 let _ = mount_resource(
3458 &resource,
3459 &field::builtin_registry(),
3460 &list::builtin_column_registry(),
3461 &std::collections::HashSet::new(),
3462 &persist::PersisterRegistry::new(),
3463 &[],
3464 );
3465 }
3466
3467 #[test]
3469 fn the_access_log_is_read_from_its_descriptor_file() {
3470 let access = builtin_resources()
3471 .into_iter()
3472 .find(|r| r.base_path == "/access-log")
3473 .expect("the access log is registered");
3474 assert_eq!(access.list.entity, "backend_access_log");
3475 assert_eq!(
3476 access.permission.as_deref(),
3477 Some("backend.view_access_log")
3478 );
3479 assert!(access.form.is_none(), "append-only");
3480 assert_eq!(
3481 access
3482 .list
3483 .columns
3484 .iter()
3485 .map(|c| c.field.as_str())
3486 .collect::<Vec<_>>(),
3487 [
3488 "created_at",
3489 "username_attempted",
3490 "event",
3491 "ip_address",
3492 "user_agent"
3493 ]
3494 );
3495 }
3496
3497 #[test]
3500 fn the_audit_log_is_read_from_its_descriptor_file() {
3501 let audit = builtin_resources()
3502 .into_iter()
3503 .find(|r| r.base_path == "/audit-log")
3504 .expect("the audit log is registered");
3505 assert_eq!(audit.list.entity, "backend_audit_log");
3506 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
3507 assert_eq!(
3508 audit
3509 .list
3510 .columns
3511 .iter()
3512 .map(|c| c.field.as_str())
3513 .collect::<Vec<_>>(),
3514 [
3515 "created_at",
3516 "actor_username",
3517 "action",
3518 "target_type",
3519 "target_id",
3520 "target_label"
3521 ],
3522 "in the order the file writes them"
3523 );
3524 assert_eq!(audit.list.columns[0].column_type, "datetime");
3525 assert_eq!(audit.list.per_page, 50);
3526 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
3527 assert!(audit.list.exportable, "evidence may leave the panel");
3528 assert!(audit.form.is_none(), "append-only: no form");
3529 assert!(!audit.list.deletable, "and nothing removes from it");
3530 }
3531
3532 #[test]
3533 #[should_panic(expected = "uses unregistered type `nope`")]
3534 fn unregistered_column_type_aborts_boot() {
3535 let mut resource = resource_with(None, None);
3536 let mut score = list::ListColumn::new("score", "Score");
3537 score.column_type = "nope".into();
3538 resource.list.columns.push(score);
3539 let _ = mount_resource(
3540 &resource,
3541 &field::builtin_registry(),
3542 &list::builtin_column_registry(),
3543 &std::collections::HashSet::new(),
3544 &persist::PersisterRegistry::new(),
3545 &[],
3546 );
3547 }
3548
3549 #[test]
3550 fn read_only_resource_may_omit_permission() {
3551 let resource = resource_with(None, None);
3553 let _ = mount_resource(
3554 &resource,
3555 &field::builtin_registry(),
3556 &list::builtin_column_registry(),
3557 &std::collections::HashSet::new(),
3558 &persist::PersisterRegistry::new(),
3559 &[],
3560 );
3561 }
3562
3563 #[test]
3564 fn shell_localizes_through_the_translator_and_falls_back() {
3565 let shell = Shell::test();
3567 assert_eq!(shell.t("Save"), "Save");
3568 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
3569 }
3570
3571 #[test]
3572 fn shell_tf_interpolates_integer_arguments() {
3573 let shell = Shell::test();
3574 assert_eq!(
3575 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
3576 "Page 2 of 5"
3577 );
3578 }
3579
3580 #[test]
3581 fn display_tz_prefers_a_valid_operator_preference() {
3582 assert_eq!(
3583 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
3584 Tz::Asia__Kolkata
3585 );
3586 }
3587
3588 #[test]
3589 fn display_tz_falls_back_when_unset_or_invalid() {
3590 let default = Tz::Europe__London;
3591 assert_eq!(resolve_display_tz(None, default), default);
3593 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
3595 assert_eq!(resolve_display_tz(Some(""), default), default);
3596 }
3597
3598 #[test]
3599 fn accept_language_orders_by_weight_and_drops_wildcard() {
3600 assert_eq!(
3601 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
3602 vec!["en-US", "en", "kn"]
3603 );
3604 assert_eq!(
3606 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
3607 vec!["kn", "en"]
3608 );
3609 }
3610
3611 #[test]
3612 fn locale_chain_prefers_operator_then_header_then_default() {
3613 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
3615 assert_eq!(
3618 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
3619 vec!["kn", "ta", "hi", "en"]
3620 );
3621 assert_eq!(
3623 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
3624 vec!["kn", "en", "ta"]
3625 );
3626 assert_eq!(
3628 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
3629 vec!["kn", "en"]
3630 );
3631 }
3632
3633 #[test]
3634 fn locale_chain_skips_unserveable_and_defaults_to_en() {
3635 let loaded = ["hi".to_string()];
3637 assert_eq!(
3640 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
3641 vec!["en"]
3642 );
3643 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
3645 assert_eq!(
3647 resolve_locale_chain(Some("hi"), None, "en", &loaded),
3648 vec!["hi", "en"]
3649 );
3650 assert_eq!(
3652 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
3653 vec![laterite_core::PSEUDO_LOCALE, "en"]
3654 );
3655 }
3656
3657 #[test]
3658 fn page_assets_dedupe_preserve_order_and_classify() {
3659 let mut reg = AssetRegistry::new();
3660 reg.insert(
3661 "a.js",
3662 AdminAsset {
3663 mime: "text/javascript; charset=utf-8",
3664 bytes: std::borrow::Cow::Borrowed(b"//"),
3665 },
3666 );
3667 reg.insert(
3668 "b.css",
3669 AdminAsset {
3670 mime: "text/css; charset=utf-8",
3671 bytes: std::borrow::Cow::Borrowed(b"body{}"),
3672 },
3673 );
3674 let urls = asset_urls(®);
3677 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
3678 assert_eq!(assets.len(), 2);
3679 assert_eq!(
3680 assets[0].url,
3681 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
3682 );
3683 assert!(!assets[0].css);
3684 assert_eq!(
3685 assets[1].url,
3686 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
3687 );
3688 assert!(assets[1].css);
3689 assert_ne!(assets[0].url, assets[1].url);
3692 }
3693
3694 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
3698 laterite_core::testing::connect_test(&[]).await
3699 }
3700
3701 #[tokio::test]
3702 async fn builtin_migrations_create_the_admin_tables() {
3703 let (db, _guard) = empty_db().await;
3704 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
3705 .await
3706 .unwrap();
3707 for table in ["backend_users", "settings"] {
3711 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
3712 .fetch_optional(&db.pool)
3713 .await;
3714 assert!(
3715 probe.is_ok(),
3716 "{table} should exist after builtin_migrations"
3717 );
3718 }
3719 }
3720
3721 #[tokio::test]
3722 async fn brand_setting_overrides_config_and_blank_falls_back() {
3723 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
3724 let state = AdminState {
3725 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3726 db: db.clone(),
3727 nav: Arc::new(Vec::new()),
3728 settings: Arc::new(Vec::new()),
3729 permissions: Arc::new(builtin_permissions()),
3730 trusted_proxies: Arc::new(Vec::new()),
3731 icons: Arc::new(laterite_core::icons::IconSet::new()),
3732 sprite_url: Arc::from(""),
3733 admin_path: Arc::from("/admin"),
3734 secure_cookie: false,
3735 origin: Arc::from(""),
3736 timezone: Tz::UTC,
3737 default_locale: Arc::from("en"),
3738 catalogs: Arc::new(CatalogStore::default()),
3739 app_name: "Configured Name".to_string(),
3740 brand_cache: Arc::new(RwLock::new(None)),
3741 field_types: Arc::new(field::builtin_registry()),
3742 column_types: Arc::new(list::builtin_column_registry()),
3743 plugin_defined: Arc::new(laterite_core::Registry::new()),
3744 assets: Arc::new(builtin_assets()),
3745 asset_urls: Arc::new(asset_urls(&builtin_assets())),
3746 pickers: Arc::new(picker::PickerRegistry::new()),
3747 overrides: Arc::new(field::NoOverrides),
3748 };
3749
3750 assert_eq!(state.brand().await, "Configured Name");
3752
3753 settings::store::save(
3756 &db,
3757 &settings::BrandSetting {
3758 app_name: "Acme Corp".to_string(),
3759 },
3760 )
3761 .await
3762 .unwrap();
3763 state.invalidate_brand();
3764 assert_eq!(state.brand().await, "Acme Corp");
3765
3766 settings::store::save(
3768 &db,
3769 &settings::BrandSetting {
3770 app_name: " ".to_string(),
3771 },
3772 )
3773 .await
3774 .unwrap();
3775 state.invalidate_brand();
3776 assert_eq!(state.brand().await, "Configured Name");
3777 }
3778
3779 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
3780 settings::SettingsItem {
3781 code: code.to_string(),
3782 label: code.into(),
3783 description: String::new().into(),
3784 category: "General".into(),
3785 hint: None,
3786 confirm_leave: true,
3787 order: 1,
3788 icon: None,
3789 permission: permission.map(str::to_string),
3790 link: None,
3791 fields: Vec::new(),
3792 route: None,
3793 }
3794 }
3795
3796 #[test]
3797 fn settings_visibility_respects_permissions() {
3798 let items = vec![
3799 settings_item("public", None),
3800 settings_item("gated", Some("backend.manage_users")),
3801 ];
3802
3803 let none = PermissionSet::new(false, Vec::<String>::new());
3805 let codes: Vec<String> = visible_settings(&items, &none)
3806 .into_iter()
3807 .map(|i| i.code)
3808 .collect();
3809 assert_eq!(codes, ["public"]);
3810
3811 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
3813 assert_eq!(visible_settings(&items, &granted).len(), 2);
3814
3815 let superuser = PermissionSet::new(true, Vec::<String>::new());
3817 assert_eq!(visible_settings(&items, &superuser).len(), 2);
3818 }
3819
3820 #[test]
3821 fn context_sidebar_follows_settings_links() {
3822 let items = builtin_settings();
3823 let superuser = PermissionSet::new(true, Vec::<String>::new());
3824 let tr = Translator::new("en");
3825 let sidebar = |path: &str| {
3826 resolve_nav_context(
3827 &[],
3828 &items,
3829 "/admin",
3830 &superuser,
3831 path,
3832 &tr,
3833 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
3834 )
3835 .0
3836 };
3837 let active_path = |path: &str| -> Option<String> {
3838 sidebar(path)
3839 .into_iter()
3840 .flat_map(|g| g.items)
3841 .find(|i| i.active)
3842 .map(|i| i.path)
3843 };
3844
3845 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
3847 assert_eq!(
3848 active_path("/admin/roles/42/edit").as_deref(),
3849 Some("/admin/roles")
3850 );
3851 assert!(!sidebar("/admin/settings").is_empty());
3853 assert_eq!(active_path("/admin/settings"), None);
3854 assert_eq!(
3859 active_path("/admin/settings/branding").as_deref(),
3860 Some("/admin/settings/branding")
3861 );
3862 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
3865 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
3866 assert!(sidebar("/admin").is_empty());
3868 }
3869
3870 #[test]
3871 fn active_nav_lights_the_right_tab() {
3872 let nav = vec![
3873 NavLink {
3874 label: "Dashboard".into(),
3875 path: "/admin".to_string(),
3876 icon: Some("layout-dashboard"),
3877 },
3878 NavLink {
3879 label: "Pages".into(),
3880 path: "/admin/pages".to_string(),
3881 icon: None,
3882 },
3883 NavLink {
3884 label: "Settings".into(),
3885 path: "/admin/settings".to_string(),
3886 icon: Some("settings"),
3887 },
3888 ];
3889
3890 assert_eq!(
3892 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
3893 Some("/admin")
3894 );
3895 assert_eq!(
3897 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
3898 Some("/admin/pages")
3899 );
3900 assert_eq!(
3902 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
3903 None
3904 );
3905 assert_eq!(
3908 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
3909 None
3910 );
3911 assert_eq!(
3914 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
3915 Some("/admin/settings")
3916 );
3917 assert_eq!(
3918 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
3919 Some("/admin/settings")
3920 );
3921 }
3922}
3923
3924#[cfg(test)]
3925mod session_cookie_tests {
3926 use super::*;
3927
3928 #[test]
3932 fn the_session_cookie_never_outlives_the_browser() {
3933 let cookie = session_cookie("tok".into(), "/admin", false);
3934 assert_eq!(cookie.max_age(), None);
3935 assert!(cookie.http_only().unwrap_or(false));
3936 assert_eq!(cookie.path(), Some("/admin"));
3937 }
3938
3939 #[test]
3942 fn the_remember_cookie_carries_its_own_lifetime() {
3943 let credential = laterite_auth::RememberCredential {
3944 cookie: "sel:ver".into(),
3945 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
3946 };
3947 let cookie = remember_cookie(credential, "/admin", true);
3948 let age = cookie.max_age().expect("a remember cookie must persist");
3949 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
3951 assert!(cookie.http_only().unwrap_or(false));
3952 assert!(cookie.secure().unwrap_or(false));
3953 }
3954
3955 #[test]
3957 fn the_removal_matches_the_cookie_it_clears() {
3958 let removal = remember_removal("/admin");
3959 assert_eq!(removal.name(), REMEMBER_COOKIE);
3960 assert_eq!(removal.path(), Some("/admin"));
3961 }
3962}