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