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 ]
994}
995
996pub fn descriptor_sources() -> Vec<String> {
1002 let mut out = Vec::new();
1003 out.extend(laterite_core::collect_sources(&builtin_resources()));
1004 out.extend(laterite_core::collect_sources(&builtin_settings()));
1005 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1006 out.sort();
1007 out.dedup();
1008 out
1009}
1010
1011pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1026 builtin_modules().iter().map(|m| m.migrations()).collect()
1027}
1028
1029pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1033 vec![
1034 Box::new(laterite_auth::AuthModule),
1035 Box::new(settings::SettingsModule),
1036 Box::new(plugins::PluginsModule),
1037 ]
1038}
1039
1040#[derive(Default)]
1064pub struct Contributions {
1065 pub resources: Vec<Resource>,
1067 pub settings: Vec<settings::SettingsItem>,
1069 pub permissions: Vec<Permission>,
1071 pub picker_sources: Vec<picker::PickerSourceReg>,
1073 pub persisters: Vec<persist::PersisterReg>,
1075 pub listeners: Vec<laterite_core::ModelListenerReg>,
1077 pub field_types: Vec<field::FieldTypeReg>,
1081 pub column_types: Vec<list::ColumnTypeReg>,
1082 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1085 pub screens: Vec<routes::ScreenReg>,
1087 pub public_routes: Vec<routes::PublicRouteReg>,
1089 pub plugin_defined: Arc<laterite_core::Registry>,
1094}
1095
1096pub fn router(
1097 auth: AuthService,
1098 db: Db,
1099 contributions: Contributions,
1100 config: AdminConfig,
1101 catalogs: Arc<CatalogStore>,
1102) -> Router {
1103 let Contributions {
1104 resources: app_resources,
1105 settings: app_settings,
1106 permissions: app_permissions,
1107 picker_sources: app_picker_sources,
1108 persisters: app_persisters,
1109 listeners: app_listeners,
1110 field_types: app_field_types,
1111 column_types: app_column_types,
1112 icons: app_icons,
1113 screens: app_screens,
1114 public_routes: app_public,
1115 plugin_defined,
1116 } = contributions;
1117 let admin_path = normalize_path(&config.path);
1118
1119 let mut resources = builtin_resources();
1120 let mut app_resources = app_resources;
1121 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1125 prefix_resource(&admin_path, resource);
1126 }
1127
1128 let mut settings = builtin_settings();
1129 settings.extend(app_settings);
1130 let mut app_listeners = app_listeners;
1133 app_listeners.insert(
1134 0,
1135 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1136 );
1137
1138 let mut permissions = builtin_permissions();
1139 permissions.extend(app_permissions);
1140 let permission_codes: std::collections::HashSet<String> =
1142 permissions.iter().map(|p| p.code.clone()).collect();
1143
1144 let mut nav = vec![NavLink {
1148 label: "Dashboard".into(),
1149 path: admin_path.clone(),
1150 icon: Some("layout-dashboard"),
1151 }];
1152 for resource in &app_resources {
1153 nav.push(NavLink {
1154 label: resource.nav_label.clone(),
1155 path: resource.base_path.clone(),
1156 icon: None,
1157 });
1158 }
1159 for reg in &app_screens {
1162 if let Some(label) = ®.nav_label {
1163 nav.push(NavLink {
1164 label: label.clone(),
1165 path: format!("{admin_path}{}", reg.base_path),
1166 icon: None,
1167 });
1168 }
1169 }
1170 nav.push(NavLink {
1171 label: "Settings".into(),
1172 path: format!("{admin_path}/settings"),
1173 icon: Some("settings"),
1174 });
1175 resources.extend(app_resources);
1176
1177 let app_name = if config.app_name.trim().is_empty() {
1178 "Laterite".to_string()
1179 } else {
1180 config.app_name.clone()
1181 };
1182
1183 let mut pickers = picker::PickerRegistry::new();
1186 for reg in app_picker_sources {
1187 let name = reg.name.clone();
1188 if !field::is_name(&name, true) {
1189 panic!("invalid picker source name `{name}`");
1190 }
1191 if pickers.insert(name.clone(), reg).is_some() {
1192 panic!("duplicate picker source `{name}`");
1193 }
1194 }
1195
1196 let pickers = Arc::new(pickers);
1200 let mut field_types = field::builtin_registry();
1201 field_types.insert(
1202 "reference".to_string(),
1203 Arc::new(field::RefPickerField::new(pickers.clone())),
1204 );
1205
1206 let mut persisters = persist::PersisterRegistry::new();
1209 for reg in app_persisters {
1210 let name = reg.name.clone();
1211 if !field::is_name(&name, true) {
1212 panic!("invalid persister name `{name}`");
1213 }
1214 if persisters.insert(name.clone(), reg.persister).is_some() {
1215 panic!("duplicate persister `{name}`");
1216 }
1217 }
1218
1219 for reg in app_field_types {
1225 let name = reg.name().to_string();
1226 if !field::is_name(&name, true) {
1227 panic!("invalid field type name `{name}`");
1228 }
1229 if field_types.insert(name.clone(), reg.field_type).is_some() {
1230 panic!("duplicate field type `{name}`");
1231 }
1232 }
1233
1234 let mut icon_set = laterite_core::icons::IconSet::new();
1238 for (owner, reg) in app_icons {
1239 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1240 panic!("module `{owner}` contributed an invalid icon: {e}");
1241 }
1242 }
1243
1244 let mut assets = builtin_assets();
1247 assets.insert(
1248 ICON_SPRITE,
1249 AdminAsset {
1250 mime: "image/svg+xml",
1251 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1252 },
1253 );
1254
1255 for item in &settings {
1259 icons::require(
1260 &icon_set,
1261 &format!("settings item `{}`", item.code),
1262 item.icon.as_deref(),
1263 );
1264 }
1265 for link in &nav {
1266 icons::require(&icon_set, &format!("menu entry `{}`", link.path), link.icon);
1267 }
1268
1269 let mut column_types = list::builtin_column_registry();
1273 for reg in app_column_types {
1274 let name = reg.name().to_string();
1275 if !field::is_name(&name, true) {
1276 panic!("invalid column type name `{name}`");
1277 }
1278 if column_types.insert(name.clone(), reg.column_type).is_some() {
1279 panic!("duplicate column type `{name}`");
1280 }
1281 }
1282
1283 let settings = Arc::new(settings);
1284 let asset_urls_arc = Arc::new(asset_urls(&assets));
1286 let sprite_url: Arc<str> = Arc::from(
1287 asset_urls_arc
1288 .get(ICON_SPRITE)
1289 .map(|p| format!("{admin_path}/assets/{p}"))
1290 .unwrap_or_default()
1291 .as_str(),
1292 );
1293 let icons_arc = Arc::new(icon_set);
1294 let state = AdminState {
1295 auth,
1296 db,
1297 nav: Arc::new(nav),
1298 settings: settings.clone(),
1299 permissions: Arc::new(permissions),
1300 admin_path: Arc::from(admin_path.as_str()),
1301 secure_cookie: config.secure_cookie,
1302 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1303 icons: icons_arc.clone(),
1304 sprite_url: sprite_url.clone(),
1305 origin: Arc::from(config.origin.trim_end_matches('/')),
1306 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1307 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1308 catalogs,
1309 app_name,
1310 brand_cache: Arc::new(RwLock::new(None)),
1311 field_types: Arc::new(field_types),
1312 column_types: Arc::new(column_types),
1313 plugin_defined,
1314 assets: Arc::new(assets),
1315 asset_urls: asset_urls_arc.clone(),
1316 pickers,
1317 overrides: Arc::new(field::NoOverrides),
1318 };
1319
1320 let mut protected = Router::new().route(&admin_path, get(dashboard));
1321 for resource in &resources {
1322 protected = protected.merge(mount_resource(
1323 resource,
1324 &state.field_types,
1325 &state.column_types,
1326 &permission_codes,
1327 &persisters,
1328 &app_listeners,
1329 ));
1330 }
1331 for reg in &app_screens {
1335 let base = format!("{admin_path}{}", reg.base_path);
1336 let ctx = routes::RouteCtx::new(
1337 state.db.clone(),
1338 &base,
1339 &admin_path,
1340 &state.origin,
1341 state.plugin_defined.clone(),
1342 );
1343 protected = protected.merge(guard_with_permission(
1344 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1345 ®.permission,
1346 ));
1347 }
1348
1349 protected = protected.merge(guard_with_permission(
1352 Router::new()
1353 .route(
1354 &format!("{admin_path}/roles/new"),
1355 get(roles::new_form).post(roles::create),
1356 )
1357 .route(
1358 &format!("{admin_path}/roles/{{id}}/edit"),
1359 get(roles::edit_form).post(roles::update),
1360 ),
1361 "backend.manage_roles",
1362 ));
1363 protected = protected.merge(guard_with_permission(
1366 Router::new()
1367 .route(
1368 &format!("{admin_path}/users/{{id}}/edit"),
1369 get(users::edit_form).post(users::update),
1370 )
1371 .route(
1372 &format!("{admin_path}/users/{{id}}/active"),
1373 post(users::set_active),
1374 ),
1375 "backend.manage_users",
1376 ));
1377 protected = protected.merge(guard_with_permission(
1380 Router::new()
1381 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1382 .route(
1383 &format!("{admin_path}/plugins/toggle"),
1384 post(plugins::toggle),
1385 ),
1386 plugins::MANAGE_PERMISSION,
1387 ));
1388 protected = protected
1389 .route(&format!("{admin_path}/settings"), get(settings_index))
1390 .route(
1391 &format!("{admin_path}/preferences"),
1392 get(preferences_form).post(preferences_update),
1393 )
1394 .route(
1395 &format!("{admin_path}/preferences/password"),
1396 post(password_update),
1397 )
1398 .route(
1399 &format!("{admin_path}/preferences/sessions/revoke"),
1400 post(session_revoke),
1401 )
1402 .route(
1403 &format!("{admin_path}/preferences/sessions/revoke-others"),
1404 post(session_revoke_others),
1405 )
1406 .route(
1409 &format!("{admin_path}/pickers/{{source}}/search"),
1410 any(picker::search),
1411 )
1412 .route(
1413 &format!("{admin_path}/pickers/{{source}}/resolve"),
1414 any(picker::resolve),
1415 )
1416 .route(&format!("{admin_path}/logout"), post(logout));
1417
1418 for item in settings.iter().filter(|i| i.link.is_none()) {
1422 let path = format!("{admin_path}{}", item.route());
1423 let code = item.code.clone();
1424 let edit_code = code.clone();
1425 protected = protected.route(
1426 &path,
1427 get(
1428 move |State(state): State<AdminState>,
1429 Extension(shell): Extension<Shell>,
1430 Extension(user): Extension<AuthenticatedUser>| {
1431 let code = edit_code.clone();
1432 async move { settings_edit(state, shell, user, code).await }
1433 },
1434 )
1435 .post(
1436 move |State(state): State<AdminState>,
1437 Extension(shell): Extension<Shell>,
1438 Extension(user): Extension<AuthenticatedUser>,
1439 Extension(session): Extension<session::SessionHandle>,
1440 Form(data): Form<HashMap<String, String>>| {
1441 let code = code.clone();
1442 async move { settings_update(state, shell, user, session, code, data).await }
1443 },
1444 ),
1445 );
1446 }
1447
1448 protected
1449 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1450 .route(
1454 &format!("{admin_path}/login"),
1455 get(login_form).post(login_submit),
1456 )
1457 .route(
1458 &format!("{admin_path}/setup"),
1459 get(setup_form).post(setup_submit),
1460 )
1461 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1462 .merge(mount_public(
1466 &app_public,
1467 &state.db,
1468 &admin_path,
1469 &state.origin,
1470 &state.plugin_defined,
1471 ))
1472 .fallback(not_found_fallback)
1474 .layer(middleware::from_fn_with_state(
1477 state.clone(),
1478 enforce_origin,
1479 ))
1480 .layer(middleware::from_fn_with_state(
1483 state.clone(),
1484 capture_client,
1485 ))
1486 .layer(
1491 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1492 header::CACHE_CONTROL,
1493 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1494 ),
1495 )
1496 .layer(
1500 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1501 header::VARY,
1502 axum::http::HeaderValue::from_static("HX-Request"),
1503 ),
1504 )
1505 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1506 handle_panic,
1507 ))
1508 .with_state(state)
1509}
1510
1511async fn not_found_fallback() -> AdminError {
1513 AdminError::NotFound
1514}
1515
1516fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1518 let cause = err
1519 .downcast_ref::<&str>()
1520 .map(|s| s.to_string())
1521 .or_else(|| err.downcast_ref::<String>().cloned())
1522 .unwrap_or_else(|| "unknown panic".to_string());
1523 tracing::error!(panic = cause, "admin handler panicked");
1524 error::masked_500()
1525}
1526
1527pub fn normalize_path(path: &str) -> String {
1532 let trimmed = path.trim().trim_matches('/');
1533 if trimmed.is_empty() {
1534 "/admin".to_string()
1535 } else {
1536 format!("/{trimmed}")
1537 }
1538}
1539
1540fn mount_public(
1544 regs: &[routes::PublicRouteReg],
1545 db: &Db,
1546 admin_path: &str,
1547 origin: &str,
1548 plugin_defined: &Arc<laterite_core::Registry>,
1549) -> Router<AdminState> {
1550 let mut router = Router::new();
1551 for reg in regs {
1552 let ctx = routes::RouteCtx::new(
1553 db.clone(),
1554 ®.path,
1555 admin_path,
1556 origin,
1557 plugin_defined.clone(),
1558 );
1559 router = router.nest_service(
1564 ®.path,
1565 reg.route.mount(&ctx).fallback(not_found_fallback),
1566 );
1567 }
1568 router
1569}
1570
1571pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1574 let old = resource.base_path.clone();
1575 resource.base_path = base.to_string();
1576 if let Some(edit_base) = &mut resource.list.edit_base {
1577 if edit_base == &old {
1578 *edit_base = base.to_string();
1579 }
1580 }
1581 if let Some(form) = &mut resource.form {
1582 if form.base_path == old {
1583 form.base_path = base.to_string();
1584 }
1585 }
1586}
1587
1588fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1589 resource.base_path = format!("{admin_path}{}", resource.base_path);
1590 if let Some(edit_base) = &mut resource.list.edit_base {
1591 *edit_base = format!("{admin_path}{edit_base}");
1592 }
1593 if let Some(form) = &mut resource.form {
1594 form.base_path = format!("{admin_path}{}", form.base_path);
1595 }
1596}
1597
1598pub(crate) struct AdminAsset {
1600 pub mime: &'static str,
1601 pub bytes: std::borrow::Cow<'static, [u8]>,
1605}
1606
1607pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1610
1611pub(crate) type AssetUrls = HashMap<&'static str, String>;
1615
1616pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
1618 registry
1619 .iter()
1620 .map(|(&key, asset)| {
1621 (
1622 key,
1623 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
1624 )
1625 })
1626 .collect()
1627}
1628
1629pub(crate) fn page_assets(
1636 keys: &[&str],
1637 base: &str,
1638 registry: &AssetRegistry,
1639 urls: &AssetUrls,
1640) -> Vec<PageAsset> {
1641 let mut seen = std::collections::HashSet::new();
1642 let mut out = Vec::new();
1643 for &key in keys {
1644 if !seen.insert(key) {
1645 continue;
1646 }
1647 match (registry.get(key), urls.get(key)) {
1648 (Some(asset), Some(path)) => out.push(PageAsset {
1649 url: format!("{base}/assets/{path}"),
1650 css: asset.mime.contains("css"),
1651 }),
1652 _ => debug_assert!(false, "asset key `{key}` is not registered"),
1653 }
1654 }
1655 out
1656}
1657
1658pub(crate) fn builtin_assets() -> AssetRegistry {
1667 let font = |bytes: &'static [u8]| AdminAsset {
1668 mime: "font/woff2",
1669 bytes: std::borrow::Cow::Borrowed(bytes),
1670 };
1671 HashMap::from([
1672 (
1673 "laterite.css",
1674 AdminAsset {
1675 mime: "text/css; charset=utf-8",
1676 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
1677 },
1678 ),
1679 (
1680 "laterite.js",
1681 AdminAsset {
1682 mime: "text/javascript; charset=utf-8",
1683 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
1684 },
1685 ),
1686 (
1687 "vendor/htmx.min.js",
1688 AdminAsset {
1689 mime: "text/javascript; charset=utf-8",
1690 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
1691 },
1692 ),
1693 (
1694 "fields/ref-picker.js",
1695 AdminAsset {
1696 mime: "text/javascript; charset=utf-8",
1697 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
1698 },
1699 ),
1700 (
1701 "fields/ref-picker.css",
1702 AdminAsset {
1703 mime: "text/css; charset=utf-8",
1704 bytes: std::borrow::Cow::Borrowed(include_bytes!(
1705 "../assets/fields/ref-picker.css"
1706 )),
1707 },
1708 ),
1709 (
1710 "mark.svg",
1711 AdminAsset {
1712 mime: "image/svg+xml",
1713 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
1714 },
1715 ),
1716 (
1717 "mark.png",
1718 AdminAsset {
1719 mime: "image/png",
1720 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
1721 },
1722 ),
1723 (
1724 "fonts/space-grotesk-500.woff2",
1725 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1726 ),
1727 (
1728 "fonts/space-grotesk-600.woff2",
1729 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1730 ),
1731 (
1732 "fonts/space-grotesk-700.woff2",
1733 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1734 ),
1735 (
1736 "fonts/ibm-plex-sans-400.woff2",
1737 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1738 ),
1739 (
1740 "fonts/ibm-plex-sans-600.woff2",
1741 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1742 ),
1743 (
1744 "fonts/ibm-plex-mono-400.woff2",
1745 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1746 ),
1747 (
1748 "fonts/ibm-plex-mono-600.woff2",
1749 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1750 ),
1751 ])
1752}
1753
1754async fn serve_asset(
1756 State(state): State<AdminState>,
1757 headers: HeaderMap,
1758 Path(path): Path<String>,
1759) -> Response {
1760 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
1764 let Some(asset) = state.assets.get(key.as_str()) else {
1765 return not_found();
1766 };
1767 let policy = if fingerprinted {
1768 http_cache::IMMUTABLE
1769 } else {
1770 http_cache::REVALIDATE
1771 };
1772 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
1773}
1774
1775fn mount_resource(
1780 resource: &Resource,
1781 field_types: &field::FieldRegistry,
1782 column_types: &list::ColumnRegistry,
1783 permission_codes: &std::collections::HashSet<String>,
1784 persisters: &persist::PersisterRegistry,
1785 listeners: &[laterite_core::ModelListenerReg],
1786) -> Router<AdminState> {
1787 list::check(&resource.list, column_types, permission_codes)
1791 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
1792 if resource.form.is_some() && resource.permission.is_none() {
1797 panic!(
1798 "admin resource `{}` has a create/edit form but no permission; a write \
1799 resource must declare a permission",
1800 resource.base_path
1801 );
1802 }
1803 let base = resource.base_path.clone();
1804 let list_path = base.clone();
1805 let list_cfg = resource.list.clone();
1806 let mut router = Router::new().route(
1807 &base,
1808 get(
1809 move |State(state): State<AdminState>,
1810 Extension(shell): Extension<Shell>,
1811 Query(params): Query<list::ListParams>,
1812 Query(raw): Query<std::collections::HashMap<String, String>>,
1813 Extension(user): Extension<AuthenticatedUser>,
1814 headers: axum::http::HeaderMap| {
1815 let cfg = list_cfg.clone();
1816 let path = list_path.clone();
1817 async move {
1818 list::handle(&state, &cfg, &path, params, &raw, &user, shell, &headers).await
1819 }
1820 },
1821 ),
1822 );
1823
1824 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
1827 if resource.list.exportable {
1828 router = router.route(
1829 &format!("{base}/export"),
1830 get(
1831 move |state: State<AdminState>,
1832 Query(raw): Query<std::collections::HashMap<String, String>>,
1833 Extension(user): Extension<AuthenticatedUser>,
1834 Extension(shell): Extension<Shell>,
1835 Extension(session): Extension<session::SessionHandle>| {
1836 let cfg = export_cfg.clone();
1837 let path = export_path.clone();
1838 async move {
1839 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
1840 }
1841 },
1842 ),
1843 );
1844 }
1845
1846 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
1847 router = router.route(
1848 &format!("{base}/columns"),
1849 post(
1850 move |State(state): State<AdminState>,
1851 Extension(user): Extension<AuthenticatedUser>,
1852 Extension(session): Extension<session::SessionHandle>,
1853 headers: axum::http::HeaderMap,
1854 Form(pairs): Form<Vec<(String, String)>>| {
1855 let cfg = columns_cfg.clone();
1856 let path = columns_path.clone();
1857 async move {
1858 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
1859 }
1860 },
1861 ),
1862 );
1863
1864 if resource.list.deletable {
1865 let persister: Arc<dyn persist::Persister> =
1869 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
1870 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
1871 panic!(
1872 "admin resource `{}`: unregistered persister `{key}`",
1873 resource.base_path
1874 )
1875 }),
1876 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
1877 };
1878 let ctx = Arc::new(bulk::BulkContext {
1879 base_path: resource.base_path.clone(),
1880 entity: resource.list.entity.clone(),
1881 persister,
1882 listeners: listeners
1883 .iter()
1884 .filter(|reg| reg.matches(&resource.list.entity))
1885 .map(|reg| reg.listener.clone())
1886 .collect(),
1887 });
1888 router =
1889 router.route(
1890 &format!("{base}/delete"),
1891 post(
1892 move |state: State<AdminState>,
1893 user: Extension<AuthenticatedUser>,
1894 session: Extension<session::SessionHandle>,
1895 headers: axum::http::HeaderMap,
1896 form: Form<Vec<(String, String)>>| {
1897 let ctx = ctx.clone();
1898 async move {
1899 bulk::delete_handler(state, user, session, ctx, headers, form).await
1900 }
1901 },
1902 ),
1903 );
1904 }
1905
1906 if let Some(form_cfg) = resource.form.clone() {
1907 let prepared = Arc::new(
1910 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
1911 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1912 );
1913 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1914 router = router.route(
1915 &format!("{base}/new"),
1916 get(
1917 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1918 let pf = new_pf.clone();
1919 async move { form::new_form(&state, &pf, shell) }
1920 },
1921 )
1922 .post(
1923 move |State(state): State<AdminState>,
1924 Extension(shell): Extension<Shell>,
1925 Extension(user): Extension<AuthenticatedUser>,
1926 Extension(session): Extension<session::SessionHandle>,
1927 headers: axum::http::HeaderMap,
1928 Form(data): Form<HashMap<String, String>>| {
1929 let pf = create_pf.clone();
1930 async move {
1931 form::create(&state, &pf, data, shell, &user, &session, &headers).await
1932 }
1933 },
1934 ),
1935 );
1936
1937 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1938 router = router.route(
1939 &format!("{base}/{{id}}/edit"),
1940 get(
1941 move |State(state): State<AdminState>,
1942 Extension(shell): Extension<Shell>,
1943 Path(id): Path<String>| {
1944 let pf = edit_pf.clone();
1945 async move { form::edit_form(&state, &pf, id, shell).await }
1946 },
1947 )
1948 .post(
1949 move |State(state): State<AdminState>,
1950 Extension(shell): Extension<Shell>,
1951 Extension(user): Extension<AuthenticatedUser>,
1952 Extension(session): Extension<session::SessionHandle>,
1953 Path(id): Path<String>,
1954 headers: axum::http::HeaderMap,
1955 Form(data): Form<HashMap<String, String>>| {
1956 let pf = update_pf.clone();
1957 async move {
1958 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
1959 }
1960 },
1961 ),
1962 );
1963 }
1964
1965 if let Some(permission) = &resource.permission {
1967 router = guard_with_permission(router, permission);
1968 }
1969 router
1970}
1971
1972fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
1977 let needed: Arc<str> = Arc::from(permission);
1978 router.route_layer(middleware::from_fn(
1979 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
1980 let needed = needed.clone();
1981 async move {
1982 if user.allows(&needed) {
1983 next.run(req).await
1984 } else {
1985 forbidden()
1986 }
1987 }
1988 },
1989 ))
1990}
1991
1992async fn require_auth(
1998 State(state): State<AdminState>,
1999 jar: CookieJar,
2000 request: Request,
2001 next: Next,
2002) -> Response {
2003 let login = format!("{}/login", state.admin_path);
2004 let client_ctx = request
2005 .extensions()
2006 .get::<RequestContext>()
2007 .cloned()
2008 .unwrap_or_default();
2009 let mut ended: Option<laterite_auth::SessionEnd> = None;
2014 let live = match jar.get(SESSION_COOKIE) {
2015 Some(cookie) => {
2016 let token = cookie.value().to_string();
2017 match state.auth.resolve_session(&token).await {
2018 Ok(resolved) => Some((token, resolved)),
2019 Err(_) => {
2020 ended = state.auth.session_end(&token).await.ok().flatten();
2021 None
2022 }
2023 }
2024 }
2025 None => None,
2026 };
2027 let htmx = request.headers().contains_key("hx-request");
2028 let (token, resolved, recalled) = match live {
2029 Some((token, resolved)) => (token, resolved, None),
2030 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2033 return signed_out(&state, jar, &login, ended, htmx);
2034 }
2035 None => match recall(&state, &jar, &client_ctx).await {
2036 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2037 None => {
2038 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2042 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2043 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2044 }
2045 }
2046 return signed_out(&state, jar, &login, ended, htmx);
2047 }
2048 },
2049 };
2050 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2051
2052 let mut request = request;
2057 let safe = session::is_safe_method(request.method());
2058 let mut deferred: Option<session::CsrfPending> = None;
2060 if !safe {
2061 let (parts, body) = request.into_parts();
2062 let (body, submitted) = if session::is_multipart(&parts.headers) {
2069 let token = session::header_token(&parts.headers)
2070 .or_else(|| session::query_token(parts.uri.query()));
2071 if token.is_none() {
2076 deferred = Some(session::CsrfPending::default());
2077 }
2078 (body, token)
2079 } else {
2080 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2081 .await
2082 .unwrap_or_default();
2083 let token = session::submitted_token(&parts.headers, &bytes);
2084 (Body::from(bytes), token)
2085 };
2086 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2087 {
2088 tracing::warn!(
2089 user_id = resolved.identity.user.id,
2090 "admin CSRF check failed"
2091 );
2092 return error::csrf_rejected();
2093 }
2094 request = Request::from_parts(parts, body);
2095 }
2096
2097 let flash = if safe {
2100 handle.take_flash()
2101 } else {
2102 Vec::new()
2103 };
2104 let user = resolved.identity;
2105 let acting_user_id = user.user.id;
2107 let path = request.uri().path().to_string();
2108 let accept_language = request
2112 .headers()
2113 .get("accept-language")
2114 .and_then(|v| v.to_str().ok());
2115 let serveable = state.catalogs.locales();
2116 let chain = resolve_locale_chain(
2117 user.user.locale.as_deref(),
2118 accept_language,
2119 &state.default_locale,
2120 &serveable,
2121 );
2122 let i18n = Translator::with_chain(chain, state.catalogs.clone());
2123 let translator = i18n.clone();
2126 let (sidebar, active_nav) = resolve_nav_context(
2127 &state.nav,
2128 &state.settings,
2129 &state.admin_path,
2130 &user.permissions,
2131 &path,
2132 &i18n,
2133 icons::Icons::new(&state.icons, &state.sprite_url),
2134 );
2135 let brand = state.brand().await;
2136 let shell = Shell::new(
2137 &state.admin_path,
2138 brand,
2139 &state.nav,
2140 &user,
2141 state.timezone,
2142 sidebar,
2143 active_nav.as_deref(),
2144 handle.csrf_token(),
2145 flash,
2146 i18n,
2147 state.asset_urls.clone(),
2148 state.origin.to_string(),
2149 state.icons.clone(),
2150 state.sprite_url.clone(),
2151 );
2152 request.extensions_mut().insert(user);
2153 request.extensions_mut().insert(shell);
2154 request.extensions_mut().insert(handle.clone());
2155 if let Some(pending) = &deferred {
2157 request.extensions_mut().insert(pending.clone());
2158 }
2159 let mut response = next.run(request).await;
2160
2161 if let Some(pending) = &deferred {
2166 if !pending.was_satisfied() {
2167 tracing::error!(
2168 user_id = acting_user_id,
2169 "a multipart route returned without verifying its request token"
2170 );
2171 return error::csrf_rejected();
2172 }
2173 }
2174
2175 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2179 response = error::localized_error(meta.kind, &translator);
2180 }
2181
2182 if let Some(blob) = handle.dirty_blob() {
2185 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2186 tracing::error!(error = %e, "persisting admin session failed");
2187 }
2188 }
2189 if let Some(credential) = recalled {
2192 let jar = jar
2193 .add(session_cookie(
2194 token,
2195 &state.admin_path,
2196 state.secure_cookie,
2197 ))
2198 .add(remember_cookie(
2199 credential,
2200 &state.admin_path,
2201 state.secure_cookie,
2202 ));
2203 return (jar, response).into_response();
2204 }
2205 response
2206}
2207
2208async fn recall(
2212 state: &AdminState,
2213 jar: &CookieJar,
2214 ctx: &RequestContext,
2215) -> Option<(
2216 String,
2217 laterite_auth::ResolvedSession,
2218 laterite_auth::RememberCredential,
2219)> {
2220 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
2221 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
2222 let token = recalled.session.token.clone();
2223 let resolved = state.auth.resolve_session(&token).await.ok()?;
2224 Some((token, resolved, recalled.remember))
2225}
2226
2227const MAX_FORM_BYTES: usize = 1024 * 1024;
2230
2231async fn capture_client(
2239 State(state): State<AdminState>,
2240 mut request: Request,
2241 next: Next,
2242) -> Response {
2243 let peer = request
2244 .extensions()
2245 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
2246 .map(|info| info.0);
2247 let headers = request.headers();
2248 let ctx = RequestContext {
2249 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
2250 user_agent: clientip::user_agent(headers),
2251 };
2252 request.extensions_mut().insert(ctx);
2253 next.run(request).await
2254}
2255
2256async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
2257 if !session::is_safe_method(request.method())
2258 && !session::origin_ok(request.headers(), &state.origin)
2259 {
2260 tracing::warn!(
2261 path = request.uri().path(),
2262 "admin CSRF origin check failed"
2263 );
2264 return error::csrf_rejected();
2265 }
2266 next.run(request).await
2267}
2268
2269#[derive(Deserialize)]
2270struct LoginQuery {
2271 ended: Option<String>,
2272}
2273
2274async fn login_form(
2275 State(state): State<AdminState>,
2276 headers: HeaderMap,
2277 Query(query): Query<LoginQuery>,
2278) -> Response {
2279 match state.auth.has_any_operator().await {
2281 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
2282 Ok(true) => {
2283 let i18n = pre_auth_translator(&state, &headers);
2284 let notice = query
2285 .ended
2286 .as_deref()
2287 .and_then(end_notice)
2288 .map(|text| i18n.t(&text));
2289 render(LoginTemplate {
2290 base: state.admin_path.to_string(),
2291 asset_urls: state.asset_urls.clone(),
2292 brand: state.brand().await,
2293 error: None,
2294 notice,
2295 i18n,
2296 })
2297 }
2298 Err(_) => render_error(),
2299 }
2300}
2301
2302#[derive(Deserialize)]
2303struct LoginForm {
2304 username: String,
2305 password: String,
2306 #[serde(default)]
2308 remember: Option<String>,
2309}
2310
2311async fn login_submit(
2312 State(state): State<AdminState>,
2313 jar: CookieJar,
2314 headers: HeaderMap,
2315 Extension(ctx): Extension<RequestContext>,
2316 Form(form): Form<LoginForm>,
2317) -> Response {
2318 match state
2319 .auth
2320 .authenticate(&form.username, &form.password, &ctx)
2321 .await
2322 {
2323 Ok(session) => {
2324 let wants_remember = form.remember.is_some();
2325 let user_id = session.user_id;
2326 let mut jar = jar.add(session_cookie(
2327 session.token,
2328 &state.admin_path,
2329 state.secure_cookie,
2330 ));
2331 if wants_remember {
2332 match state.auth.issue_remember(user_id).await {
2333 Ok(credential) => {
2334 jar = jar.add(remember_cookie(
2335 credential,
2336 &state.admin_path,
2337 state.secure_cookie,
2338 ));
2339 }
2340 Err(e) => {
2342 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
2343 }
2344 }
2345 }
2346 (jar, Redirect::to(&state.admin_path)).into_response()
2347 }
2348 Err(_) => {
2349 let i18n = pre_auth_translator(&state, &headers);
2350 render(LoginTemplate {
2351 base: state.admin_path.to_string(),
2352 asset_urls: state.asset_urls.clone(),
2353 brand: state.brand().await,
2354 error: Some(i18n.t(&t!("Invalid username or password."))),
2355 notice: None,
2356 i18n,
2357 })
2358 }
2359 }
2360}
2361
2362fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
2371 Cookie::build((SESSION_COOKIE, token))
2372 .path(admin_path.to_string())
2373 .http_only(true)
2374 .secure(secure)
2375 .same_site(SameSite::Lax)
2376 .build()
2377}
2378
2379fn remember_cookie(
2382 credential: laterite_auth::RememberCredential,
2383 admin_path: &str,
2384 secure: bool,
2385) -> Cookie<'static> {
2386 let seconds = (credential.expires_at - chrono::Utc::now())
2387 .num_seconds()
2388 .max(0);
2389 Cookie::build((REMEMBER_COOKIE, credential.cookie))
2390 .path(admin_path.to_string())
2391 .http_only(true)
2392 .secure(secure)
2393 .same_site(SameSite::Lax)
2394 .max_age(cookie::time::Duration::seconds(seconds))
2395 .build()
2396}
2397
2398fn signed_out(
2405 state: &AdminState,
2406 jar: CookieJar,
2407 login: &str,
2408 ended: Option<laterite_auth::SessionEnd>,
2409 htmx: bool,
2410) -> Response {
2411 let to = match ended.map(laterite_auth::SessionEnd::code) {
2412 Some(code) => format!("{login}?ended={code}"),
2413 None => login.to_string(),
2414 };
2415 let jar = jar
2416 .remove(
2417 Cookie::build((SESSION_COOKIE, ""))
2418 .path(state.admin_path.to_string())
2419 .build(),
2420 )
2421 .remove(remember_removal(&state.admin_path));
2422 if htmx {
2423 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
2424 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
2425 response.headers_mut().insert("hx-redirect", value);
2426 }
2427 return response;
2428 }
2429 (jar, Redirect::to(&to)).into_response()
2430}
2431
2432fn end_notice(code: &str) -> Option<Text> {
2435 Some(match code {
2436 "password_changed" => {
2437 t!("Your password was changed, so you were signed out. Sign in with the new one.")
2438 }
2439 "deactivated" => t!("This account was deactivated."),
2440 "signed_out_elsewhere" => t!("You were signed out from another device."),
2441 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
2442 _ => return None,
2443 })
2444}
2445
2446fn remember_removal(admin_path: &str) -> Cookie<'static> {
2448 Cookie::build((REMEMBER_COOKIE, ""))
2449 .path(admin_path.to_string())
2450 .build()
2451}
2452
2453#[derive(Deserialize)]
2454struct SetupForm {
2455 username: String,
2456 first_name: String,
2457 last_name: String,
2458 email: String,
2459 password: String,
2460 timezone: String,
2461}
2462
2463async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2466 match state.auth.has_any_operator().await {
2467 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2468 Ok(false) => render(setup_view(
2469 &state.admin_path,
2470 state.brand().await,
2471 state.timezone,
2472 None,
2473 pre_auth_translator(&state, &headers),
2474 state.asset_urls.clone(),
2475 )),
2476 Err(_) => render_error(),
2477 }
2478}
2479
2480async fn setup_submit(
2481 State(state): State<AdminState>,
2482 jar: CookieJar,
2483 headers: HeaderMap,
2484 Form(form): Form<SetupForm>,
2485) -> Response {
2486 match state.auth.has_any_operator().await {
2488 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2489 Ok(false) => {}
2490 Err(_) => return render_error(),
2491 }
2492
2493 let username = form.username.trim();
2494 let email = form.email.trim();
2495 let first_name = form.first_name.trim();
2496 let last_name = form.last_name.trim();
2497 let tz = form.timezone.trim();
2498 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
2499 {
2500 return render(setup_view(
2501 &state.admin_path,
2502 state.brand().await,
2503 state.timezone,
2504 Some(t!(
2505 "Username, first name, email, and password are all required."
2506 )),
2507 pre_auth_translator(&state, &headers),
2508 state.asset_urls.clone(),
2509 ));
2510 }
2511 if tz.parse::<Tz>().is_err() {
2513 return render(setup_view(
2514 &state.admin_path,
2515 state.brand().await,
2516 state.timezone,
2517 Some(t!("That is not a recognised timezone.")),
2518 pre_auth_translator(&state, &headers),
2519 state.asset_urls.clone(),
2520 ));
2521 }
2522
2523 let new = NewOperator {
2524 username,
2525 email,
2526 first_name,
2527 last_name: (!last_name.is_empty()).then_some(last_name),
2528 password: &form.password,
2529 timezone: Some(tz),
2530 };
2531 let id = match state.auth.create_superuser(new).await {
2532 Ok(id) => id,
2533 Err(_) => {
2534 return render(setup_view(
2535 &state.admin_path,
2536 state.brand().await,
2537 state.timezone,
2538 Some(t!(
2539 "Could not create the account. The username or email may already be taken."
2540 )),
2541 pre_auth_translator(&state, &headers),
2542 state.asset_urls.clone(),
2543 ));
2544 }
2545 };
2546 let entry = laterite_auth::AuditEntry {
2549 actor_user_id: None,
2550 actor_username: "first-run setup",
2551 action: "backend.user.create",
2552 target_type: Some("backend_user"),
2553 target_id: Some(&id.to_string()),
2554 detail: None,
2555 };
2556 if let Err(e) = state.auth.record_audit(entry).await {
2557 tracing::error!(error = %e, "failed to write audit log entry");
2558 }
2559
2560 match state
2562 .auth
2563 .authenticate(username, &form.password, &RequestContext::default())
2564 .await
2565 {
2566 Ok(session) => {
2567 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
2568 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
2569 }
2570 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2571 }
2572}
2573
2574fn setup_view(
2577 admin_path: &str,
2578 brand: String,
2579 default_tz: Tz,
2580 error: Option<Text>,
2581 i18n: Translator,
2582 asset_urls: Arc<AssetUrls>,
2583) -> SetupTemplate {
2584 let default_name = default_tz.name();
2585 let zones = TZ_VARIANTS
2586 .iter()
2587 .filter(|tz| zone_offered(tz.name()))
2588 .map(|tz| TzOption {
2589 name: tz.name().to_string(),
2590 selected: tz.name() == default_name,
2591 })
2592 .collect();
2593 SetupTemplate {
2594 base: admin_path.to_string(),
2595 asset_urls,
2596 brand,
2597 zones,
2598 error: error.map(|e| i18n.t(&e)),
2599 i18n,
2600 }
2601}
2602
2603async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
2604 if let Some(cookie) = jar.get(SESSION_COOKIE) {
2605 let _ = state.auth.logout(cookie.value()).await;
2606 }
2607 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2610 let _ = state.auth.revoke_remember(cookie.value()).await;
2611 }
2612 let removal = Cookie::build((SESSION_COOKIE, ""))
2613 .path(state.admin_path.to_string())
2614 .build();
2615 (
2616 jar.remove(removal)
2617 .remove(remember_removal(&state.admin_path)),
2618 Redirect::to(&format!("{}/login", state.admin_path)),
2619 )
2620 .into_response()
2621}
2622
2623async fn dashboard(
2624 Extension(shell): Extension<Shell>,
2625 Extension(user): Extension<AuthenticatedUser>,
2626) -> Response {
2627 render(DashboardTemplate {
2628 username: user.user.username,
2629 shell,
2630 })
2631}
2632
2633fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
2636 match &item.permission {
2637 None => true,
2638 Some(p) => perms.allows(p),
2639 }
2640}
2641
2642fn visible_settings(
2646 items: &[settings::SettingsItem],
2647 perms: &PermissionSet,
2648) -> Vec<settings::SettingsItem> {
2649 items
2650 .iter()
2651 .filter(|item| operator_can_see(item, perms))
2652 .cloned()
2653 .collect()
2654}
2655
2656async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
2657 settings::index(shell)
2658}
2659
2660async fn settings_edit(
2661 state: AdminState,
2662 shell: Shell,
2663 user: AuthenticatedUser,
2664 code: String,
2665) -> Response {
2666 let items = visible_settings(&state.settings, &user.permissions);
2669 match items
2670 .iter()
2671 .find(|item| item.code == code && item.link.is_none())
2672 {
2673 Some(item) => settings::edit_form(&state, item, shell).await,
2674 None => not_found(),
2675 }
2676}
2677
2678async fn settings_update(
2679 state: AdminState,
2680 shell: Shell,
2681 user: AuthenticatedUser,
2682 session: session::SessionHandle,
2683 code: String,
2684 data: HashMap<String, String>,
2685) -> Response {
2686 let items = visible_settings(&state.settings, &user.permissions);
2687 match items
2688 .iter()
2689 .find(|item| item.code == code && item.link.is_none())
2690 {
2691 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
2692 None => not_found(),
2693 }
2694}
2695
2696async fn preferences_form(
2698 State(state): State<AdminState>,
2699 Extension(shell): Extension<Shell>,
2700 Extension(user): Extension<AuthenticatedUser>,
2701 jar: CookieJar,
2702) -> Response {
2703 let account = account_security(&state, &user, &shell, &jar).await;
2704 render(preferences_view(
2705 &shell,
2706 &user,
2707 state.timezone,
2708 &state.default_locale,
2709 &offered_locales(&state.catalogs),
2710 account,
2711 ))
2712}
2713
2714#[derive(Deserialize)]
2715struct RevokeSessionForm {
2716 id: String,
2717}
2718
2719async fn session_revoke(
2721 State(state): State<AdminState>,
2722 Extension(user): Extension<AuthenticatedUser>,
2723 Extension(session): Extension<session::SessionHandle>,
2724 Form(form): Form<RevokeSessionForm>,
2725) -> Response {
2726 match state.auth.revoke_session(user.user.id, &form.id).await {
2727 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
2728 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
2729 Err(e) => {
2730 tracing::error!(error = %e, "revoking a session failed");
2731 session.push_flash(
2732 FlashLevel::Error,
2733 t!("That session could not be signed out."),
2734 );
2735 }
2736 }
2737 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2738}
2739
2740async fn session_revoke_others(
2742 State(state): State<AdminState>,
2743 Extension(user): Extension<AuthenticatedUser>,
2744 Extension(session): Extension<session::SessionHandle>,
2745 jar: CookieJar,
2746) -> Response {
2747 let token = jar
2748 .get(SESSION_COOKIE)
2749 .map(|c| c.value().to_string())
2750 .unwrap_or_default();
2751 match state.auth.sign_out_everywhere(user.user.id, &token).await {
2752 Ok(_) => session.push_flash(
2753 FlashLevel::Success,
2754 t!("Every other session was signed out."),
2755 ),
2756 Err(e) => {
2757 tracing::error!(error = %e, "signing out other sessions failed");
2758 session.push_flash(
2759 FlashLevel::Error,
2760 t!("Those sessions could not be signed out."),
2761 );
2762 }
2763 }
2764 (
2767 jar.remove(remember_removal(&state.admin_path)),
2768 Redirect::to(&format!("{}/preferences", state.admin_path)),
2769 )
2770 .into_response()
2771}
2772
2773#[derive(Deserialize)]
2774struct PasswordForm {
2775 current_password: String,
2776 new_password: String,
2777 confirm_password: String,
2778}
2779
2780async fn password_update(
2785 State(state): State<AdminState>,
2786 Extension(user): Extension<AuthenticatedUser>,
2787 Extension(session): Extension<session::SessionHandle>,
2788 Extension(ctx): Extension<RequestContext>,
2789 jar: CookieJar,
2790 Form(form): Form<PasswordForm>,
2791) -> Response {
2792 let back = format!("{}/preferences#password", state.admin_path);
2793 let refuse = |message: Text| {
2794 session.push_flash(FlashLevel::Error, message);
2795 Redirect::to(&back).into_response()
2796 };
2797 if form.new_password != form.confirm_password {
2798 return refuse(t!("The new passwords do not match."));
2799 }
2800 if form.new_password.chars().count() < laterite_auth::MIN_PASSWORD_LENGTH {
2801 return refuse(t!(
2802 "A password needs at least {n} characters.",
2803 n = laterite_auth::MIN_PASSWORD_LENGTH as i64
2804 ));
2805 }
2806 let remembered_here = match jar.get(REMEMBER_COOKIE) {
2809 Some(cookie) => {
2810 state
2811 .auth
2812 .remember_holder(cookie.value())
2813 .await
2814 .ok()
2815 .flatten()
2816 == Some(user.user.id)
2817 }
2818 None => false,
2819 };
2820 let token = jar
2821 .get(SESSION_COOKIE)
2822 .map(|c| c.value().to_string())
2823 .unwrap_or_default();
2824 let changed = state
2825 .auth
2826 .change_own_password(
2827 user.user.id,
2828 &form.current_password,
2829 &form.new_password,
2830 &token,
2831 &ctx,
2832 )
2833 .await;
2834 match changed {
2835 Ok(()) => {}
2836 Err(laterite_auth::AuthError::InvalidCredentials) => {
2837 return refuse(t!("Your current password is not correct."));
2838 }
2839 Err(laterite_auth::AuthError::TooManyAttempts) => {
2840 return refuse(t!("Too many attempts. Try again later."));
2841 }
2842 Err(e) => {
2843 tracing::error!(error = %e, "changing a password failed");
2844 return refuse(t!("The password could not be changed."));
2845 }
2846 }
2847 session.push_flash_sticky(
2848 FlashLevel::Success,
2849 t!("Password changed. Every other device was signed out."),
2850 );
2851 let jar = if remembered_here {
2852 match state.auth.issue_remember(user.user.id).await {
2853 Ok(credential) => jar.add(remember_cookie(
2854 credential,
2855 &state.admin_path,
2856 state.secure_cookie,
2857 )),
2858 Err(e) => {
2859 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
2860 jar.remove(remember_removal(&state.admin_path))
2861 }
2862 }
2863 } else if jar.get(REMEMBER_COOKIE).is_some() {
2864 jar.remove(remember_removal(&state.admin_path))
2867 } else {
2868 jar
2869 };
2870 (
2871 jar,
2872 Redirect::to(&format!("{}/preferences", state.admin_path)),
2873 )
2874 .into_response()
2875}
2876
2877#[derive(Deserialize)]
2878struct PreferencesForm {
2879 timezone: String,
2880 #[serde(default)]
2882 locale: String,
2883}
2884
2885async fn preferences_update(
2886 State(state): State<AdminState>,
2887 Extension(user): Extension<AuthenticatedUser>,
2888 Extension(session): Extension<session::SessionHandle>,
2889 Form(form): Form<PreferencesForm>,
2890) -> Response {
2891 let back = format!("{}/preferences", state.admin_path);
2892 let offered = offered_locales(&state.catalogs);
2893 let tz = form.timezone.trim();
2895 let tz_stored = if tz.is_empty() {
2896 None
2897 } else if tz.parse::<Tz>().is_ok() {
2898 Some(tz)
2899 } else {
2900 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
2901 return Redirect::to(&back).into_response();
2902 };
2903 let loc = form.locale.trim();
2904 let loc_stored = if loc.is_empty() {
2905 None
2906 } else if offered.iter().any(|l| l == loc) {
2907 Some(loc)
2908 } else {
2909 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
2910 return Redirect::to(&back).into_response();
2911 };
2912 if state
2913 .auth
2914 .set_user_timezone(user.user.id, tz_stored)
2915 .await
2916 .is_err()
2917 {
2918 return render_error();
2919 }
2920 match state.auth.set_user_locale(user.user.id, loc_stored).await {
2921 Ok(()) => {
2922 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
2923 Redirect::to(&back).into_response()
2924 }
2925 Err(_) => render_error(),
2926 }
2927}
2928
2929fn preferences_view(
2933 shell: &Shell,
2934 user: &AuthenticatedUser,
2935 default_tz: Tz,
2936 default_locale: &str,
2937 offered: &[String],
2938 account: AccountSecurity,
2939) -> PreferencesTemplate {
2940 let current = user.user.timezone.as_deref();
2941 let zones = TZ_VARIANTS
2942 .iter()
2943 .filter(|tz| zone_offered(tz.name()))
2944 .map(|tz| TzOption {
2945 name: tz.name().to_string(),
2946 selected: current == Some(tz.name()),
2947 })
2948 .collect();
2949 let current_locale = user.user.locale.as_deref();
2950 let locales = offered
2951 .iter()
2952 .map(|code| LocaleOption {
2953 name: locale_name(code),
2954 selected: current_locale == Some(code.as_str()),
2955 code: code.clone(),
2956 })
2957 .collect();
2958 PreferencesTemplate {
2959 shell: shell.clone(),
2960 zones,
2961 effective_tz: shell.tz.name().to_string(),
2962 default_tz: default_tz.name().to_string(),
2963 inherits: current.is_none(),
2964 locales,
2965 default_locale: locale_name(default_locale),
2966 inherits_locale: current_locale.is_none(),
2967 sessions: account.sessions,
2968 password_changed: account.password_changed,
2969 min_password: laterite_auth::MIN_PASSWORD_LENGTH as i64,
2970 }
2971}
2972
2973struct AccountSecurity {
2976 sessions: Vec<SessionRow>,
2977 password_changed: Option<String>,
2978}
2979
2980async fn account_security(
2981 state: &AdminState,
2982 user: &AuthenticatedUser,
2983 shell: &Shell,
2984 jar: &CookieJar,
2985) -> AccountSecurity {
2986 let locale = list::date_locale(shell.locale());
2987 let password_changed = match state.auth.password_changed_at(user.user.id).await {
2988 Ok(at) => {
2989 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
2990 }
2991 Err(e) => {
2992 tracing::error!(error = %e, "reading the password change time failed");
2993 None
2994 }
2995 };
2996 AccountSecurity {
2997 sessions: session_rows(state, user, shell, jar).await,
2998 password_changed,
2999 }
3000}
3001
3002async fn session_rows(
3005 state: &AdminState,
3006 user: &AuthenticatedUser,
3007 shell: &Shell,
3008 jar: &CookieJar,
3009) -> Vec<SessionRow> {
3010 let token = jar
3011 .get(SESSION_COOKIE)
3012 .map(|c| c.value().to_string())
3013 .unwrap_or_default();
3014 let locale = list::date_locale(shell.locale());
3015 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
3016 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
3017 };
3018 match state.auth.list_sessions(user.user.id, &token).await {
3019 Ok(sessions) => sessions
3020 .into_iter()
3021 .map(|s| SessionRow {
3022 id: s.id,
3023 started: stamp(s.created_at),
3024 last_seen: stamp(s.last_seen_at),
3025 expires: stamp(s.expires_at),
3026 device: clientip::describe(s.user_agent.as_deref())
3027 .or(s.user_agent)
3028 .unwrap_or_else(|| "-".to_string()),
3029 ip_address: s.ip_address.unwrap_or_default(),
3030 current: s.current,
3031 })
3032 .collect(),
3033 Err(e) => {
3034 tracing::error!(error = %e, "listing sessions failed");
3035 Vec::new()
3036 }
3037 }
3038}
3039
3040fn builtin_resources() -> Vec<Resource> {
3042 vec![
3043 Resource {
3044 base_path: "/users".to_string(),
3045 nav_label: "Backend Users".into(),
3046 list: backend_users_list_config(),
3047 form: None,
3048 permission: Some("backend.manage_users".to_string()),
3049 },
3050 Resource {
3051 base_path: "/roles".to_string(),
3052 nav_label: "Roles".into(),
3053 list: roles_list_config(),
3054 form: None,
3057 permission: Some("backend.manage_roles".to_string()),
3058 },
3059 resource!("descriptors/audit-log.yaml"),
3060 ]
3061}
3062
3063fn builtin_settings() -> Vec<settings::SettingsItem> {
3067 vec![
3068 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
3069 .description("Manage backend administrator accounts.")
3070 .category("Users")
3071 .order(10)
3072 .icon("users")
3073 .permission("backend.manage_users")
3074 .link("/users"),
3075 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
3076 .description("Manage roles and their permissions.")
3077 .category("Users")
3078 .order(20)
3079 .icon("shield")
3080 .permission("backend.manage_roles")
3081 .link("/roles"),
3082 settings::brand::settings_item(),
3083 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
3084 .description("Enable or disable installed plugins.")
3085 .category("System")
3086 .order(10)
3087 .icon("plug")
3088 .permission(plugins::MANAGE_PERMISSION)
3089 .link("/plugins"),
3090 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
3091 .description("Review the record of administrative changes.")
3092 .category("System")
3093 .order(20)
3094 .icon("history")
3095 .permission("backend.view_audit_log")
3096 .link("/audit-log"),
3097 ]
3098}
3099
3100fn backend_users_list_config() -> list::ListConfig {
3101 list::ListConfig {
3102 entity: "backend_users".to_string(),
3103 title: "Backend Users".into(),
3104 columns: vec![
3105 list::ListColumn::new("username", "Username"),
3106 list::ListColumn::new("email", "Email"),
3107 list::ListColumn::new("first_name", "First name"),
3108 list::ListColumn::new("last_name", "Last name"),
3109 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
3110 list::ListColumn::new("is_active", "Active").yes_no(),
3111 list::ListColumn::new("created_at", "Created").datetime(),
3112 ],
3113 order_by: "created_at".to_string(),
3114 edit_base: Some("/users".to_string()),
3117 filters: vec![
3118 list::ListFilter::boolean("is_active", "Active"),
3119 list::ListFilter::boolean("is_superuser", "Superuser"),
3120 ],
3121 ..Default::default()
3125 }
3126}
3127
3128fn roles_list_config() -> list::ListConfig {
3129 list::ListConfig {
3130 entity: "backend_roles".to_string(),
3131 title: "Roles".into(),
3132 columns: vec![
3133 list::ListColumn::new("code", "Code"),
3134 list::ListColumn::new("name", "Name"),
3135 list::ListColumn::new("created_at", "Created").datetime(),
3136 ],
3137 order_by: "created_at".to_string(),
3138 edit_base: Some("/roles".to_string()),
3139 creatable: true,
3140 deletable: true,
3141 ..Default::default()
3142 }
3143}
3144
3145#[derive(Template)]
3146#[template(path = "login.html")]
3147struct LoginTemplate {
3148 base: String,
3150 asset_urls: Arc<AssetUrls>,
3152 brand: String,
3153 error: Option<String>,
3154 notice: Option<String>,
3156 i18n: Translator,
3159}
3160
3161fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
3164 match urls.get(key) {
3165 Some(path) => format!("{base}/assets/{path}"),
3166 None => {
3167 debug_assert!(false, "asset key `{key}` is not registered");
3168 format!("{base}/assets/{key}")
3169 }
3170 }
3171}
3172
3173impl LoginTemplate {
3174 fn asset(&self, key: &str) -> String {
3175 preauth_asset(&self.asset_urls, &self.base, key)
3176 }
3177 fn t(&self, source: &str) -> String {
3178 self.i18n.t(&Text::dynamic(source))
3179 }
3180 fn locale(&self) -> &str {
3181 self.i18n.locale()
3182 }
3183}
3184
3185#[derive(Template)]
3186#[template(path = "dashboard.html")]
3187struct DashboardTemplate {
3188 shell: Shell,
3189 username: String,
3190}
3191
3192#[derive(Template)]
3193#[template(path = "setup.html")]
3194struct SetupTemplate {
3195 base: String,
3197 asset_urls: Arc<AssetUrls>,
3199 brand: String,
3200 zones: Vec<TzOption>,
3201 error: Option<String>,
3202 i18n: Translator,
3204}
3205
3206impl SetupTemplate {
3207 fn asset(&self, key: &str) -> String {
3208 preauth_asset(&self.asset_urls, &self.base, key)
3209 }
3210 fn t(&self, source: &str) -> String {
3211 self.i18n.t(&Text::dynamic(source))
3212 }
3213 fn locale(&self) -> &str {
3214 self.i18n.locale()
3215 }
3216}
3217
3218#[derive(Template)]
3219#[template(path = "preferences.html")]
3220struct PreferencesTemplate {
3221 shell: Shell,
3222 zones: Vec<TzOption>,
3223 effective_tz: String,
3225 default_tz: String,
3227 inherits: bool,
3229 locales: Vec<LocaleOption>,
3230 default_locale: String,
3232 inherits_locale: bool,
3234 sessions: Vec<SessionRow>,
3236 password_changed: Option<String>,
3238 min_password: i64,
3240}
3241
3242struct SessionRow {
3244 id: String,
3245 started: String,
3246 last_seen: String,
3247 expires: String,
3248 device: String,
3251 ip_address: String,
3253 current: bool,
3254}
3255
3256struct TzOption {
3257 name: String,
3258 selected: bool,
3259}
3260
3261struct LocaleOption {
3262 code: String,
3264 name: String,
3266 selected: bool,
3267}
3268
3269#[derive(Clone)]
3270struct NavView {
3271 label: String,
3272 path: String,
3273 active: bool,
3274 icon: String,
3277}
3278
3279pub(crate) fn render<T: Template>(template: T) -> Response {
3281 match template.render() {
3282 Ok(html) => Html(html).into_response(),
3283 Err(e) => AdminError::from(e).into_response(),
3285 }
3286}
3287
3288pub(crate) fn render_error() -> Response {
3289 error::masked_500()
3290}
3291
3292pub(crate) fn not_found() -> Response {
3293 AdminError::NotFound.into_response()
3294}
3295
3296pub(crate) fn forbidden() -> Response {
3297 AdminError::Forbidden.into_response()
3298}
3299
3300#[cfg(test)]
3301mod tests {
3302 use super::*;
3303
3304 fn write_form() -> form::FormConfig {
3305 form::FormConfig {
3306 entity: "widgets".to_string(),
3307 title: "Widgets".into(),
3308 base_path: "/widgets".to_string(),
3309 id_field: "id".to_string(),
3310 fields: vec![],
3311 persist: None,
3312 timestamps: false,
3313 }
3314 }
3315
3316 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
3317 Resource {
3318 base_path: "/widgets".to_string(),
3319 nav_label: "Widgets".into(),
3320 list: backend_users_list_config(),
3321 form,
3322 permission: permission.map(str::to_string),
3323 }
3324 }
3325
3326 #[test]
3327 #[should_panic(expected = "must declare a permission")]
3328 fn write_resource_without_permission_aborts_boot() {
3329 let resource = resource_with(Some(write_form()), None);
3332 let _ = mount_resource(
3333 &resource,
3334 &field::builtin_registry(),
3335 &list::builtin_column_registry(),
3336 &std::collections::HashSet::new(),
3337 &persist::PersisterRegistry::new(),
3338 &[],
3339 );
3340 }
3341
3342 #[test]
3345 fn the_audit_log_is_read_from_its_descriptor_file() {
3346 let audit = builtin_resources()
3347 .into_iter()
3348 .find(|r| r.base_path == "/audit-log")
3349 .expect("the audit log is registered");
3350 assert_eq!(audit.list.entity, "backend_audit_log");
3351 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
3352 assert_eq!(
3353 audit
3354 .list
3355 .columns
3356 .iter()
3357 .map(|c| c.field.as_str())
3358 .collect::<Vec<_>>(),
3359 [
3360 "created_at",
3361 "actor_username",
3362 "action",
3363 "target_type",
3364 "target_id"
3365 ],
3366 "in the order the file writes them"
3367 );
3368 assert_eq!(audit.list.columns[0].column_type, "datetime");
3369 assert_eq!(audit.list.per_page, 50);
3370 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
3371 assert!(audit.list.exportable, "evidence may leave the panel");
3372 assert!(audit.form.is_none(), "append-only: no form");
3373 assert!(!audit.list.deletable, "and nothing removes from it");
3374 }
3375
3376 #[test]
3377 #[should_panic(expected = "uses unregistered type `nope`")]
3378 fn unregistered_column_type_aborts_boot() {
3379 let mut resource = resource_with(None, None);
3380 let mut score = list::ListColumn::new("score", "Score");
3381 score.column_type = "nope".into();
3382 resource.list.columns.push(score);
3383 let _ = mount_resource(
3384 &resource,
3385 &field::builtin_registry(),
3386 &list::builtin_column_registry(),
3387 &std::collections::HashSet::new(),
3388 &persist::PersisterRegistry::new(),
3389 &[],
3390 );
3391 }
3392
3393 #[test]
3394 fn read_only_resource_may_omit_permission() {
3395 let resource = resource_with(None, None);
3397 let _ = mount_resource(
3398 &resource,
3399 &field::builtin_registry(),
3400 &list::builtin_column_registry(),
3401 &std::collections::HashSet::new(),
3402 &persist::PersisterRegistry::new(),
3403 &[],
3404 );
3405 }
3406
3407 #[test]
3408 fn shell_localizes_through_the_translator_and_falls_back() {
3409 let shell = Shell::test();
3411 assert_eq!(shell.t("Save"), "Save");
3412 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
3413 }
3414
3415 #[test]
3416 fn shell_tf_interpolates_integer_arguments() {
3417 let shell = Shell::test();
3418 assert_eq!(
3419 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
3420 "Page 2 of 5"
3421 );
3422 }
3423
3424 #[test]
3425 fn display_tz_prefers_a_valid_operator_preference() {
3426 assert_eq!(
3427 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
3428 Tz::Asia__Kolkata
3429 );
3430 }
3431
3432 #[test]
3433 fn display_tz_falls_back_when_unset_or_invalid() {
3434 let default = Tz::Europe__London;
3435 assert_eq!(resolve_display_tz(None, default), default);
3437 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
3439 assert_eq!(resolve_display_tz(Some(""), default), default);
3440 }
3441
3442 #[test]
3443 fn accept_language_orders_by_weight_and_drops_wildcard() {
3444 assert_eq!(
3445 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
3446 vec!["en-US", "en", "kn"]
3447 );
3448 assert_eq!(
3450 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
3451 vec!["kn", "en"]
3452 );
3453 }
3454
3455 #[test]
3456 fn locale_chain_prefers_operator_then_header_then_default() {
3457 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
3459 assert_eq!(
3462 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
3463 vec!["kn", "ta", "hi", "en"]
3464 );
3465 assert_eq!(
3467 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
3468 vec!["kn", "en", "ta"]
3469 );
3470 assert_eq!(
3472 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
3473 vec!["kn", "en"]
3474 );
3475 }
3476
3477 #[test]
3478 fn locale_chain_skips_unserveable_and_defaults_to_en() {
3479 let loaded = ["hi".to_string()];
3481 assert_eq!(
3484 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
3485 vec!["en"]
3486 );
3487 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
3489 assert_eq!(
3491 resolve_locale_chain(Some("hi"), None, "en", &loaded),
3492 vec!["hi", "en"]
3493 );
3494 assert_eq!(
3496 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
3497 vec![laterite_core::PSEUDO_LOCALE, "en"]
3498 );
3499 }
3500
3501 #[test]
3502 fn page_assets_dedupe_preserve_order_and_classify() {
3503 let mut reg = AssetRegistry::new();
3504 reg.insert(
3505 "a.js",
3506 AdminAsset {
3507 mime: "text/javascript; charset=utf-8",
3508 bytes: std::borrow::Cow::Borrowed(b"//"),
3509 },
3510 );
3511 reg.insert(
3512 "b.css",
3513 AdminAsset {
3514 mime: "text/css; charset=utf-8",
3515 bytes: std::borrow::Cow::Borrowed(b"body{}"),
3516 },
3517 );
3518 let urls = asset_urls(®);
3521 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
3522 assert_eq!(assets.len(), 2);
3523 assert_eq!(
3524 assets[0].url,
3525 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
3526 );
3527 assert!(!assets[0].css);
3528 assert_eq!(
3529 assets[1].url,
3530 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
3531 );
3532 assert!(assets[1].css);
3533 assert_ne!(assets[0].url, assets[1].url);
3536 }
3537
3538 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
3542 laterite_core::testing::connect_test(&[]).await
3543 }
3544
3545 #[tokio::test]
3546 async fn builtin_migrations_create_the_admin_tables() {
3547 let (db, _guard) = empty_db().await;
3548 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
3549 .await
3550 .unwrap();
3551 for table in ["backend_users", "settings"] {
3555 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
3556 .fetch_optional(&db.pool)
3557 .await;
3558 assert!(
3559 probe.is_ok(),
3560 "{table} should exist after builtin_migrations"
3561 );
3562 }
3563 }
3564
3565 #[tokio::test]
3566 async fn brand_setting_overrides_config_and_blank_falls_back() {
3567 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
3568 let state = AdminState {
3569 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3570 db: db.clone(),
3571 nav: Arc::new(Vec::new()),
3572 settings: Arc::new(Vec::new()),
3573 permissions: Arc::new(builtin_permissions()),
3574 trusted_proxies: Arc::new(Vec::new()),
3575 icons: Arc::new(laterite_core::icons::IconSet::new()),
3576 sprite_url: Arc::from(""),
3577 admin_path: Arc::from("/admin"),
3578 secure_cookie: false,
3579 origin: Arc::from(""),
3580 timezone: Tz::UTC,
3581 default_locale: Arc::from("en"),
3582 catalogs: Arc::new(CatalogStore::default()),
3583 app_name: "Configured Name".to_string(),
3584 brand_cache: Arc::new(RwLock::new(None)),
3585 field_types: Arc::new(field::builtin_registry()),
3586 column_types: Arc::new(list::builtin_column_registry()),
3587 plugin_defined: Arc::new(laterite_core::Registry::new()),
3588 assets: Arc::new(builtin_assets()),
3589 asset_urls: Arc::new(asset_urls(&builtin_assets())),
3590 pickers: Arc::new(picker::PickerRegistry::new()),
3591 overrides: Arc::new(field::NoOverrides),
3592 };
3593
3594 assert_eq!(state.brand().await, "Configured Name");
3596
3597 settings::store::save(
3600 &db,
3601 &settings::BrandSetting {
3602 app_name: "Acme Corp".to_string(),
3603 },
3604 )
3605 .await
3606 .unwrap();
3607 state.invalidate_brand();
3608 assert_eq!(state.brand().await, "Acme Corp");
3609
3610 settings::store::save(
3612 &db,
3613 &settings::BrandSetting {
3614 app_name: " ".to_string(),
3615 },
3616 )
3617 .await
3618 .unwrap();
3619 state.invalidate_brand();
3620 assert_eq!(state.brand().await, "Configured Name");
3621 }
3622
3623 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
3624 settings::SettingsItem {
3625 code: code.to_string(),
3626 label: code.into(),
3627 description: String::new().into(),
3628 category: "General".into(),
3629 hint: None,
3630 order: 1,
3631 icon: None,
3632 permission: permission.map(str::to_string),
3633 link: None,
3634 fields: Vec::new(),
3635 route: None,
3636 }
3637 }
3638
3639 #[test]
3640 fn settings_visibility_respects_permissions() {
3641 let items = vec![
3642 settings_item("public", None),
3643 settings_item("gated", Some("backend.manage_users")),
3644 ];
3645
3646 let none = PermissionSet::new(false, Vec::<String>::new());
3648 let codes: Vec<String> = visible_settings(&items, &none)
3649 .into_iter()
3650 .map(|i| i.code)
3651 .collect();
3652 assert_eq!(codes, ["public"]);
3653
3654 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
3656 assert_eq!(visible_settings(&items, &granted).len(), 2);
3657
3658 let superuser = PermissionSet::new(true, Vec::<String>::new());
3660 assert_eq!(visible_settings(&items, &superuser).len(), 2);
3661 }
3662
3663 #[test]
3664 fn context_sidebar_follows_settings_links() {
3665 let items = builtin_settings();
3666 let superuser = PermissionSet::new(true, Vec::<String>::new());
3667 let tr = Translator::new("en");
3668 let sidebar = |path: &str| {
3669 resolve_nav_context(
3670 &[],
3671 &items,
3672 "/admin",
3673 &superuser,
3674 path,
3675 &tr,
3676 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
3677 )
3678 .0
3679 };
3680 let active_path = |path: &str| -> Option<String> {
3681 sidebar(path)
3682 .into_iter()
3683 .flat_map(|g| g.items)
3684 .find(|i| i.active)
3685 .map(|i| i.path)
3686 };
3687
3688 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
3690 assert_eq!(
3691 active_path("/admin/roles/42/edit").as_deref(),
3692 Some("/admin/roles")
3693 );
3694 assert!(!sidebar("/admin/settings").is_empty());
3696 assert_eq!(active_path("/admin/settings"), None);
3697 assert_eq!(
3702 active_path("/admin/settings/branding").as_deref(),
3703 Some("/admin/settings/branding")
3704 );
3705 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
3708 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
3709 assert!(sidebar("/admin").is_empty());
3711 }
3712
3713 #[test]
3714 fn active_nav_lights_the_right_tab() {
3715 let nav = vec![
3716 NavLink {
3717 label: "Dashboard".into(),
3718 path: "/admin".to_string(),
3719 icon: Some("layout-dashboard"),
3720 },
3721 NavLink {
3722 label: "Pages".into(),
3723 path: "/admin/pages".to_string(),
3724 icon: None,
3725 },
3726 NavLink {
3727 label: "Settings".into(),
3728 path: "/admin/settings".to_string(),
3729 icon: Some("settings"),
3730 },
3731 ];
3732
3733 assert_eq!(
3735 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
3736 Some("/admin")
3737 );
3738 assert_eq!(
3740 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
3741 Some("/admin/pages")
3742 );
3743 assert_eq!(
3745 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
3746 None
3747 );
3748 assert_eq!(
3751 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
3752 None
3753 );
3754 assert_eq!(
3757 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
3758 Some("/admin/settings")
3759 );
3760 assert_eq!(
3761 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
3762 Some("/admin/settings")
3763 );
3764 }
3765}
3766
3767#[cfg(test)]
3768mod session_cookie_tests {
3769 use super::*;
3770
3771 #[test]
3775 fn the_session_cookie_never_outlives_the_browser() {
3776 let cookie = session_cookie("tok".into(), "/admin", false);
3777 assert_eq!(cookie.max_age(), None);
3778 assert!(cookie.http_only().unwrap_or(false));
3779 assert_eq!(cookie.path(), Some("/admin"));
3780 }
3781
3782 #[test]
3785 fn the_remember_cookie_carries_its_own_lifetime() {
3786 let credential = laterite_auth::RememberCredential {
3787 cookie: "sel:ver".into(),
3788 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
3789 };
3790 let cookie = remember_cookie(credential, "/admin", true);
3791 let age = cookie.max_age().expect("a remember cookie must persist");
3792 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
3794 assert!(cookie.http_only().unwrap_or(false));
3795 assert!(cookie.secure().unwrap_or(false));
3796 }
3797
3798 #[test]
3800 fn the_removal_matches_the_cookie_it_clears() {
3801 let removal = remember_removal("/admin");
3802 assert_eq!(removal.name(), REMEMBER_COOKIE);
3803 assert_eq!(removal.path(), Some("/admin"));
3804 }
3805}