1mod audit;
18pub mod bootstrap;
19mod bulk;
20pub mod checklist;
21mod clientip;
22pub mod descriptor;
23mod error;
24mod export;
25pub mod field;
26pub mod form;
27pub mod html;
28pub mod http_cache;
29mod icons;
30pub mod import;
31pub(crate) mod keyed;
32pub mod list;
33pub mod nav;
34pub(crate) mod palette;
35pub mod persist;
36pub mod picker;
37pub mod plugins;
38pub mod relation;
39pub(crate) mod requestlog;
40mod roles;
41mod routemap;
42pub mod routes;
43mod session;
44pub mod settings;
45mod sql;
46pub mod ui;
47mod upload;
48mod users;
49
50pub use axum;
58
59pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
60pub use error::AdminError;
61pub use session::{FlashLevel, SessionHandle};
62pub use upload::VerifiedUpload;
63
64use std::collections::HashMap;
65use std::sync::{Arc, RwLock};
66
67use askama::Template;
68use axum::body::Body;
69use axum::extract::{Path, Query, Request, State};
70use axum::http::{header, HeaderMap};
71use axum::middleware::{self, Next};
72use axum::response::{Html, IntoResponse, Redirect, Response};
73use axum::routing::{any, get, post};
74use axum::{Extension, Form, Router};
75use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
76use chrono_tz::{Tz, TZ_VARIANTS};
77use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
78use laterite_core::{t, CatalogStore, Db, Text, Translator};
79use serde::{Deserialize, Serialize};
80
81pub trait AdminRegistry {
87 fn add_resource(&mut self, resource: Resource);
89 fn add_permission(&mut self, permission: Permission);
91 fn add_settings(&mut self, item: settings::SettingsItem);
93 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
95 fn add_persister(&mut self, persister: persist::PersisterReg);
97 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
99 fn add_screen(&mut self, screen: routes::ScreenReg);
102 fn add_public_route(&mut self, route: routes::PublicRouteReg);
105 fn add_nav(&mut self, item: nav::NavItem);
107 fn add_resource_hook(&mut self, hook: ResourceHook);
109}
110
111impl AdminRegistry for laterite_core::Registry {
112 fn add_resource(&mut self, resource: Resource) {
113 self.add(resource);
114 }
115 fn add_permission(&mut self, permission: Permission) {
116 self.add(permission);
117 }
118 fn add_settings(&mut self, item: settings::SettingsItem) {
119 self.add(item);
120 }
121 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
122 self.add(source);
123 }
124 fn add_screen(&mut self, screen: routes::ScreenReg) {
125 self.add(screen);
126 }
127 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
128 self.add(route);
129 }
130 fn add_nav(&mut self, item: nav::NavItem) {
131 self.add(item);
132 }
133 fn add_resource_hook(&mut self, hook: ResourceHook) {
134 self.add(hook);
135 }
136 fn add_persister(&mut self, persister: persist::PersisterReg) {
137 self.add(persister);
138 }
139 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
140 self.add(field_type);
141 }
142}
143
144#[derive(Clone)]
151pub struct ResourceHook {
152 pub target: String,
153 apply: Arc<dyn Fn(Resource) -> Resource + Send + Sync>,
154}
155
156impl ResourceHook {
157 pub fn new(
158 target: impl Into<String>,
159 apply: impl Fn(Resource) -> Resource + Send + Sync + 'static,
160 ) -> Self {
161 Self {
162 target: target.into(),
163 apply: Arc::new(apply),
164 }
165 }
166
167 pub fn apply(&self, resource: Resource) -> Resource {
169 (self.apply)(resource)
170 }
171}
172
173impl std::fmt::Debug for ResourceHook {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 write!(f, "ResourceHook({})", self.target)
176 }
177}
178
179const SESSION_COOKIE: &str = "laterite_session";
180const REMEMBER_COOKIE: &str = "laterite_remember";
184
185pub(crate) const ICON_SPRITE: &str = "icons.svg";
188
189#[derive(Clone)]
191pub(crate) struct AdminState {
192 auth: AuthService,
193 db: Db,
194 nav: Arc<nav::NavTree>,
195 settings: Arc<Vec<settings::SettingsItem>>,
196 permissions: Arc<Vec<Permission>>,
197 admin_path: Arc<str>,
201 secure_cookie: bool,
202 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
204 icons: Arc<laterite_core::icons::IconSet>,
206 sprite_url: Arc<str>,
208 origin: Arc<str>,
210 timezone: Tz,
211 default_locale: Arc<str>,
215 catalogs: Arc<CatalogStore>,
218 app_name: String,
221 brand_cache: Arc<RwLock<Option<String>>>,
225 asset_urls: Arc<AssetUrls>,
227 field_types: Arc<field::FieldRegistry>,
230 column_types: Arc<list::ColumnRegistry>,
233 plugin_defined: Arc<laterite_core::Registry>,
236 assets: Arc<AssetRegistry>,
238 pickers: Arc<picker::PickerRegistry>,
241 palette: Arc<Vec<palette::RecordSource>>,
244 overrides: Arc<dyn field::OverrideResolver>,
248}
249
250impl AdminState {
251 #[cfg(test)]
252 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
253 Self {
254 auth,
255 db,
256 nav: Arc::new(nav::NavTree::default()),
257 settings: Arc::new(Vec::new()),
258 permissions: Arc::new(builtin_permissions()),
259 trusted_proxies: Arc::new(Vec::new()),
260 icons: Arc::new(laterite_core::icons::IconSet::new()),
261 sprite_url: Arc::from(""),
262 admin_path: Arc::from("/admin"),
263 secure_cookie: false,
264 origin: Arc::from(""),
265 timezone: Tz::UTC,
266 default_locale: Arc::from("en"),
267 catalogs: Arc::new(CatalogStore::default()),
268 app_name: "Laterite".to_string(),
269 brand_cache: Arc::new(RwLock::new(None)),
270 field_types: Arc::new(field::builtin_registry()),
271 column_types: Arc::new(list::builtin_column_registry()),
272 plugin_defined: Arc::new(laterite_core::Registry::new()),
273 assets: Arc::new(builtin_assets()),
274 asset_urls: Arc::new(asset_urls(&builtin_assets())),
275 pickers: Arc::new(picker::PickerRegistry::new()),
276 palette: Arc::new(Vec::new()),
277 overrides: Arc::new(field::NoOverrides),
278 }
279 }
280
281 async fn brand(&self) -> String {
285 {
286 let cached = self.brand_cache.read().unwrap().clone();
287 if let Some(name) = cached {
288 return name;
289 }
290 }
291 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
292 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
293 _ => self.app_name.clone(),
294 };
295 *self.brand_cache.write().unwrap() = Some(resolved.clone());
296 resolved
297 }
298
299 fn invalidate_brand(&self) {
301 *self.brand_cache.write().unwrap() = None;
302 }
303}
304
305#[derive(Clone)]
311#[non_exhaustive]
312pub struct AdminConfig {
313 pub secure_cookie: bool,
316 pub trusted_proxies: Vec<String>,
320 pub timezone: String,
324 pub locale: String,
328 pub app_name: String,
332 pub path: String,
336 pub origin: String,
341 pub storage_dir: String,
346}
347
348impl Default for AdminConfig {
349 fn default() -> Self {
350 Self {
351 secure_cookie: false,
352 trusted_proxies: Vec::new(),
353 timezone: "UTC".to_string(),
354 locale: "en".to_string(),
355 app_name: "Laterite".to_string(),
356 path: "/admin".to_string(),
357 origin: String::new(),
358 storage_dir: "storage".to_string(),
359 }
360 }
361}
362
363#[derive(Clone)]
372pub struct Shell {
373 pub(crate) base: String,
377 brand: String,
381 nav: Vec<NavView>,
382 full_name: String,
383 initial: String,
384 tz: Tz,
388 i18n: Translator,
392 sidebar: Vec<settings::CategoryView>,
396 crumbs: Vec<CrumbView>,
399 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
401 pub(crate) sprite_url: Arc<str>,
404 site_url: String,
408 asset_urls: Arc<AssetUrls>,
412 pub(crate) csrf_token: String,
416 pub(crate) flash: Vec<FlashLine>,
420 pub(crate) assets: Vec<PageAsset>,
424}
425
426#[derive(Template)]
428#[template(path = "page.html")]
429struct PageTemplate {
430 title: String,
431 body: html::Markup,
432 shell: Shell,
433}
434
435#[derive(Clone)]
437pub(crate) struct PageAsset {
438 pub(crate) url: String,
439 pub(crate) css: bool,
440}
441
442#[derive(Clone)]
445pub(crate) struct FlashLine {
446 pub(crate) level: session::FlashLevel,
447 pub(crate) text: String,
448 pub(crate) sticky: bool,
450}
451
452impl Shell {
453 #[allow(clippy::too_many_arguments)]
454 fn new(
455 base: &str,
456 brand: String,
457 nav: &[nav::NavNode],
458 user: &AuthenticatedUser,
459 default_tz: Tz,
460 sidebar: Vec<settings::CategoryView>,
461 active_nav: Option<&str>,
462 csrf_token: String,
463 flash: Vec<session::Flash>,
464 i18n: Translator,
465 asset_urls: Arc<AssetUrls>,
466 site_url: String,
467 icon_set: Arc<laterite_core::icons::IconSet>,
468 sprite_url: Arc<str>,
469 ) -> Self {
470 let full_name = user.user.full_name();
471 let initial = full_name
472 .chars()
473 .next()
474 .map(|c| c.to_uppercase().to_string())
475 .unwrap_or_else(|| "?".to_string());
476 let flash = flash
479 .into_iter()
480 .map(|f| FlashLine {
481 sticky: f.sticky,
482 level: f.level,
483 text: i18n.t(&f.text),
484 })
485 .collect();
486 let nav: Vec<NavView> = nav
487 .iter()
488 .map(|n| NavView {
489 key: n.item.key.clone(),
490 label: i18n.t(&n.item.label),
491 path: nav::tab_path(n).unwrap_or_default().to_string(),
492 active: active_nav == Some(n.item.key.as_str()),
493 icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
494 })
495 .collect();
496 let mut crumbs: Vec<CrumbView> = nav
497 .iter()
498 .filter(|n: &&NavView| n.active)
499 .map(|n| CrumbView {
500 label: n.label.clone(),
501 href: n.path.clone(),
502 })
503 .collect();
504 if let Some(entry) = sidebar
505 .iter()
506 .flat_map(|g| g.items.iter())
507 .find(|i| i.active)
508 {
509 crumbs.push(CrumbView {
510 label: entry.label.clone(),
511 href: entry.path.clone(),
512 });
513 }
514 Shell {
515 asset_urls,
516 icon_set,
517 sprite_url,
518 site_url,
519 base: base.to_string(),
520 brand,
521 nav,
522 crumbs,
523 full_name,
524 initial,
525 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
526 i18n,
527 sidebar,
528 csrf_token,
529 flash,
530 assets: Vec::new(),
531 }
532 }
533
534 pub(crate) fn site_url(&self) -> &str {
536 &self.site_url
537 }
538
539 pub(crate) fn sidebar_anchored(&self) -> bool {
543 self.nav.iter().any(|n| n.active)
544 }
545
546 pub(crate) fn icon(&self, name: &str) -> String {
547 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
548 }
549
550 pub(crate) fn asset(&self, key: &str) -> String {
555 match self.asset_urls.get(key) {
556 Some(path) => format!("{}/assets/{path}", self.base),
557 None => {
558 debug_assert!(false, "asset key `{key}` is not registered");
559 format!("{}/assets/{key}", self.base)
560 }
561 }
562 }
563
564 pub fn t(&self, source: &str) -> String {
567 self.i18n.t(&Text::dynamic(source))
568 }
569
570 pub fn tt(&self, text: &Text) -> String {
573 self.i18n.t(text)
574 }
575
576 pub fn i18n(&self) -> &Translator {
579 &self.i18n
580 }
581
582 pub fn locale(&self) -> &str {
584 self.i18n.locale()
585 }
586
587 pub fn base(&self) -> &str {
589 &self.base
590 }
591
592 pub fn csrf_token(&self) -> &str {
596 &self.csrf_token
597 }
598
599 pub fn datetime(&self, utc: &str) -> String {
602 match chrono::DateTime::parse_from_rfc3339(utc) {
603 Ok(at) => at
604 .with_timezone(&self.tz)
605 .format("%Y-%m-%d %H:%M")
606 .to_string(),
607 Err(_) => utc.to_string(),
608 }
609 }
610
611 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
615 render(PageTemplate {
616 title: self.tt(title),
617 body,
618 shell: self.clone(),
619 })
620 }
621
622 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
626 let mut text = Text::dynamic(source);
627 for (name, value) in args {
628 text = text.arg(*name, *value);
629 }
630 self.i18n.t(&text)
631 }
632
633 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
636 let mut text = Text::dynamic(source);
637 for (name, value) in args {
638 text = text.arg(*name, *value);
639 }
640 self.i18n.t(&text)
641 }
642
643 #[cfg(test)]
644 pub(crate) fn test() -> Self {
645 Shell {
646 asset_urls: Arc::new(asset_urls(&builtin_assets())),
647 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
648 sprite_url: Arc::from("/admin/assets/icons.svg"),
649 site_url: "http://localhost".to_string(),
650 base: "/admin".to_string(),
651 brand: "Laterite".to_string(),
652 nav: Vec::new(),
653 full_name: "Test Operator".to_string(),
654 initial: "T".to_string(),
655 tz: Tz::UTC,
656 i18n: Translator::new("en"),
657 sidebar: Vec::new(),
658 crumbs: Vec::new(),
659 csrf_token: "test-csrf-token".to_string(),
660 flash: Vec::new(),
661 assets: Vec::new(),
662 }
663 }
664}
665
666fn settings_context(
673 visible: &[settings::SettingsItem],
674 admin_path: &str,
675 path: &str,
676) -> (bool, Option<String>) {
677 if path == format!("{admin_path}/settings") {
678 return (true, None);
679 }
680 let own = visible
686 .iter()
687 .filter(|i| i.link.is_none())
688 .find(|i| path == format!("{admin_path}{}", i.route()))
689 .map(|i| i.code.clone());
690 if own.is_some() {
691 return (true, own);
692 }
693 let linked = visible
694 .iter()
695 .filter_map(|i| {
696 i.link
697 .as_deref()
698 .map(|link| (format!("{admin_path}{link}"), &i.code))
699 })
700 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
701 .max_by_key(|(link, _)| link.len())
702 .map(|(_, code)| code.clone());
703 (linked.is_some(), linked)
704}
705
706#[allow(clippy::too_many_arguments)]
712fn resolve_nav_context(
713 nav: &nav::NavTree,
714 items: &[settings::SettingsItem],
715 admin_path: &str,
716 perms: &PermissionSet,
717 own_order: Option<&str>,
718 path: &str,
719 tr: &Translator,
720 icons: icons::Icons<'_>,
721) -> (
722 Vec<nav::NavNode>,
723 Vec<settings::CategoryView>,
724 Option<String>,
725) {
726 let mut visible_nav = nav.visible(&|code| perms.allows(code));
727 if let Some(order) = own_order {
728 nav::apply_order(&mut visible_nav, order);
729 }
730 let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
732 .into_iter()
733 .filter(|i| !i.per_operator)
734 .collect();
735 let (in_context, active) = settings_context(&visible, admin_path, path);
736 if in_context {
737 let sidebar = settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons);
738 return (visible_nav, sidebar, Some("settings".to_string()));
739 }
740 let active_top = nav::active_top(&visible_nav, admin_path, path);
741 let sidebar = active_top
742 .map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
743 .unwrap_or_default();
744 let active_key = active_top.map(|node| node.item.key.clone());
745 (visible_nav, sidebar, active_key)
746}
747
748fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
752 preference
753 .and_then(|name| name.parse::<Tz>().ok())
754 .unwrap_or(default_tz)
755}
756
757fn zone_offered(name: &str) -> bool {
759 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
760}
761
762fn base_lang(tag: &str) -> String {
765 tag.split(['-', '_'])
766 .next()
767 .unwrap_or(tag)
768 .to_ascii_lowercase()
769}
770
771fn is_serveable(base: &str, serveable: &[String]) -> bool {
777 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
778}
779
780fn default_locale(configured: &str, serveable: &[String]) -> String {
783 let base = base_lang(configured);
784 if is_serveable(&base, serveable) {
785 base
786 } else {
787 "en".to_string()
788 }
789}
790
791fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
795 let mut out = vec!["en".to_string()];
796 out.extend(catalogs.locales());
797 out
798}
799
800fn locale_name(code: &str) -> String {
805 if code == "en" {
806 "English".to_string()
807 } else {
808 code.to_string()
809 }
810}
811
812fn parse_accept_language(header: &str) -> Vec<String> {
815 let mut items: Vec<(f32, String)> = header
816 .split(',')
817 .filter_map(|part| {
818 let mut bits = part.split(';');
819 let tag = bits.next()?.trim();
820 if tag.is_empty() || tag == "*" {
821 return None;
822 }
823 let q = bits
824 .find_map(|p| p.trim().strip_prefix("q="))
825 .and_then(|q| q.parse::<f32>().ok())
826 .unwrap_or(1.0);
827 Some((q, tag.to_string()))
828 })
829 .collect();
830 items.sort_by(|a, b| b.0.total_cmp(&a.0));
832 items.into_iter().map(|(_, tag)| tag).collect()
833}
834
835fn resolve_locale_chain(
841 preference: Option<&str>,
842 accept_language: Option<&str>,
843 default: &str,
844 serveable: &[String],
845) -> Vec<String> {
846 let mut chain: Vec<String> = Vec::new();
847 let consider = |tag: &str, chain: &mut Vec<String>| {
848 let base = base_lang(tag);
849 if is_serveable(&base, serveable) && !chain.contains(&base) {
850 chain.push(base);
851 }
852 };
853 if let Some(pref) = preference {
854 consider(pref, &mut chain);
855 }
856 if let Some(header) = accept_language {
857 for tag in parse_accept_language(header) {
858 consider(&tag, &mut chain);
859 }
860 }
861 consider(default, &mut chain);
862 consider("en", &mut chain);
865 chain
866}
867
868fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
872 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
873 let serveable = state.catalogs.locales();
874 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
875 Translator::with_chain(chain, state.catalogs.clone())
876}
877
878#[derive(Serialize)]
886#[non_exhaustive]
887pub struct Resource {
888 pub base_path: String,
894 pub nav_label: Text,
896 pub list: list::ListConfig,
897 pub form: Option<form::FormConfig>,
898 pub permission: Option<String>,
905 pub import: Option<ImportConfig>,
908 pub palette: Option<String>,
911 pub relations: Vec<relation::RelationConfig>,
914 pub menu: nav::Placement,
917}
918
919#[derive(Clone, Debug, Default, Serialize, Deserialize)]
922#[non_exhaustive]
923pub struct ImportConfig {
924 #[serde(default)]
927 pub permission: Option<String>,
928}
929
930impl ImportConfig {
931 pub fn new() -> Self {
932 Self::default()
933 }
934
935 pub fn permission(mut self, code: impl Into<String>) -> Self {
938 self.permission = Some(code.into());
939 self
940 }
941}
942
943#[derive(Clone, Serialize)]
948#[non_exhaustive]
949pub struct Permission {
950 pub code: String,
951 pub label: Text,
953 pub group: Text,
954 pub default_roles: Vec<String>,
957 pub renamed_from: Vec<String>,
960}
961
962pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
963
964impl Permission {
965 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
968 Self {
969 code: code.into(),
970 label: label.into(),
971 group: group.into(),
972 default_roles: Vec::new(),
973 renamed_from: Vec::new(),
974 }
975 }
976
977 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
983 where
984 I: IntoIterator<Item = S>,
985 S: Into<String>,
986 {
987 self.renamed_from = codes.into_iter().map(Into::into).collect();
988 self
989 }
990
991 pub fn roles<I, S>(mut self, roles: I) -> Self
994 where
995 I: IntoIterator<Item = S>,
996 S: Into<String>,
997 {
998 self.default_roles = roles.into_iter().map(Into::into).collect();
999 self
1000 }
1001}
1002
1003pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
1009 let permissions: Vec<Permission> = builtin_permissions()
1010 .into_iter()
1011 .chain(contributed.iter().cloned())
1012 .collect();
1013 let mut out: Vec<(String, String)> = Vec::new();
1014 for permission in &permissions {
1015 for old in &permission.renamed_from {
1016 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
1017 panic!(
1018 "permissions `{taken}` and `{}` both claim the old code `{old}`",
1019 permission.code
1020 );
1021 }
1022 if permissions.iter().any(|p| &p.code == old) {
1023 panic!(
1024 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
1025 permission.code
1026 );
1027 }
1028 out.push((old.clone(), permission.code.clone()));
1029 }
1030 }
1031 out
1032}
1033
1034pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
1040 let permissions: Vec<Permission> = builtin_permissions()
1043 .into_iter()
1044 .chain(contributed.iter().cloned())
1045 .collect();
1046 let held = |role: &str| -> Vec<String> {
1047 permissions
1048 .iter()
1049 .filter(|p| {
1050 if p.default_roles.is_empty() {
1051 role == ROLE_ADMIN
1052 } else {
1053 p.default_roles.iter().any(|r| r == role)
1054 }
1055 })
1056 .map(|p| p.code.clone())
1057 .collect()
1058 };
1059 vec![
1060 laterite_auth::store::SystemRole {
1061 code: ROLE_ADMIN,
1062 name: "Administrator",
1063 description: "Administers the panel itself: operators, roles and settings.",
1064 permissions: held(ROLE_ADMIN),
1065 },
1066 laterite_auth::store::SystemRole {
1067 code: ROLE_EDITOR,
1068 name: "Editor",
1069 description: "Works with the content the application declares.",
1070 permissions: held(ROLE_EDITOR),
1071 },
1072 ]
1073}
1074
1075impl Resource {
1076 pub fn new(
1081 base_path: impl Into<String>,
1082 nav_label: impl Into<Text>,
1083 list: list::ListConfig,
1084 ) -> Self {
1085 Self {
1086 base_path: base_path.into(),
1087 nav_label: nav_label.into(),
1088 list,
1089 form: None,
1090 permission: None,
1091 import: None,
1092 palette: None,
1093 relations: Vec::new(),
1094 menu: nav::Placement::default(),
1095 }
1096 }
1097
1098 pub fn palette(mut self, source: impl Into<String>) -> Self {
1101 self.palette = Some(source.into());
1102 self
1103 }
1104
1105 pub fn menu(mut self, placement: nav::Placement) -> Self {
1107 self.menu = placement;
1108 self
1109 }
1110
1111 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1113 self.relations.push(relation);
1114 self
1115 }
1116
1117 pub fn import(mut self, import: ImportConfig) -> Self {
1119 self.import = Some(import);
1120 self
1121 }
1122
1123 pub fn form(mut self, form: form::FormConfig) -> Self {
1126 self.form = Some(form);
1127 self
1128 }
1129
1130 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1132 self.permission = Some(permission.into());
1133 self
1134 }
1135}
1136
1137struct SystemRoleGuard;
1143
1144#[laterite_core::strata::async_trait]
1145impl laterite_core::ModelListener for SystemRoleGuard {
1146 async fn before_delete(
1147 &self,
1148 cx: &mut laterite_core::SaveCx<'_>,
1149 rec: &laterite_core::Record,
1150 ) -> Result<(), Text> {
1151 let Some(id) = rec.id() else {
1152 return Ok(());
1153 };
1154 let backend = cx.backend();
1156 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1157 Ok(true) => Err(t!(
1158 "The framework maintains this role; it cannot be deleted."
1159 )),
1160 Err(_) => Err(t!(
1162 "Could not check whether this role is maintained by the framework."
1163 )),
1164 Ok(false) => Ok(()),
1165 }
1166 }
1167}
1168
1169fn builtin_permissions() -> Vec<Permission> {
1172 vec![
1175 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1176 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1177 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1178 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1179 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1180 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1181 Permission::new(
1182 "backend.view_request_log",
1183 "View the request log",
1184 "Backend",
1185 ),
1186 ]
1187}
1188
1189pub fn descriptor_sources() -> Vec<String> {
1195 let mut out = Vec::new();
1196 out.extend(laterite_core::collect_sources(&builtin_resources()));
1197 out.extend(laterite_core::collect_sources(&builtin_settings()));
1198 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1199 out.sort();
1200 out.dedup();
1201 out
1202}
1203
1204pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1219 builtin_modules().iter().map(|m| m.migrations()).collect()
1220}
1221
1222pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1226 vec![
1227 Box::new(laterite_auth::AuthModule),
1228 Box::new(settings::SettingsModule),
1229 Box::new(requestlog::RequestLogModule),
1230 Box::new(plugins::PluginsModule),
1231 Box::new(laterite_media::MediaModule),
1232 Box::new(import::ImportModule),
1233 ]
1234}
1235
1236#[derive(Default)]
1260pub struct Contributions {
1261 pub resources: Vec<Resource>,
1263 pub settings: Vec<settings::SettingsItem>,
1265 pub permissions: Vec<Permission>,
1267 pub picker_sources: Vec<picker::PickerSourceReg>,
1269 pub persisters: Vec<persist::PersisterReg>,
1271 pub listeners: Vec<laterite_core::ModelListenerReg>,
1273 pub field_types: Vec<field::FieldTypeReg>,
1277 pub column_types: Vec<list::ColumnTypeReg>,
1278 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1281 pub screens: Vec<routes::ScreenReg>,
1283 pub public_routes: Vec<routes::PublicRouteReg>,
1285 pub nav: Vec<nav::NavItem>,
1288 pub resource_hooks: Vec<ResourceHook>,
1291 pub plugin_defined: Arc<laterite_core::Registry>,
1296}
1297
1298pub fn router(
1299 auth: AuthService,
1300 db: Db,
1301 contributions: Contributions,
1302 config: AdminConfig,
1303 catalogs: Arc<CatalogStore>,
1304) -> Router {
1305 let Contributions {
1306 resources: app_resources,
1307 settings: app_settings,
1308 permissions: app_permissions,
1309 picker_sources: app_picker_sources,
1310 persisters: app_persisters,
1311 listeners: app_listeners,
1312 field_types: app_field_types,
1313 column_types: app_column_types,
1314 icons: app_icons,
1315 screens: app_screens,
1316 public_routes: app_public,
1317 nav: app_nav,
1318 resource_hooks,
1319 plugin_defined,
1320 } = contributions;
1321 let admin_path = normalize_path(&config.path);
1322
1323 let mut resources = builtin_resources();
1324 let mut app_resources = app_resources;
1325 for hook in resource_hooks {
1328 let found = resources
1329 .iter_mut()
1330 .chain(app_resources.iter_mut())
1331 .find(|r| r.base_path == hook.target);
1332 match found {
1333 Some(resource) => {
1334 let changed = hook.apply(std::mem::replace(
1335 resource,
1336 Resource::new("", "", list::ListConfig::default()),
1337 ));
1338 *resource = changed;
1339 }
1340 None => panic!(
1341 "a resource hook targets `{}`, which no module declared",
1342 hook.target
1343 ),
1344 }
1345 }
1346 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1350 prefix_resource(&admin_path, resource);
1351 }
1352 for resource in app_resources.iter_mut() {
1356 if let Some(import) = &resource.import {
1357 let permission = import
1358 .permission
1359 .clone()
1360 .or_else(|| resource.permission.clone())
1361 .unwrap_or_else(|| {
1362 panic!(
1363 "admin resource `{}` declares `import` but no permission; importing \
1364 writes records, so it must be gated",
1365 resource.base_path
1366 )
1367 });
1368 resource.list.import = Some(routes::feature_link(
1369 &resource.base_path,
1370 "import",
1371 &permission,
1372 ));
1373 }
1374 }
1375
1376 let mut settings = builtin_settings();
1377 settings.extend(app_settings);
1378 let mut app_listeners = app_listeners;
1381 app_listeners.insert(
1382 0,
1383 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1384 );
1385
1386 let mut permissions = builtin_permissions();
1387 permissions.extend(app_permissions);
1388 let permission_codes: std::collections::HashSet<String> =
1390 permissions.iter().map(|p| p.code.clone()).collect();
1391
1392 let mut nav_items = vec![
1398 nav::NavItem::new("dashboard", "Dashboard", "")
1399 .icon("layout-dashboard")
1400 .order(i32::MIN),
1401 nav::NavItem::new("settings", "Settings", "/settings")
1402 .icon("settings")
1403 .order(i32::MAX),
1404 ];
1405 nav_items.extend(app_nav);
1406 for resource in &app_resources {
1407 if resource.menu.hidden {
1408 continue;
1409 }
1410 let unprefixed = resource
1411 .base_path
1412 .strip_prefix(admin_path.as_str())
1413 .unwrap_or(&resource.base_path)
1414 .to_string();
1415 let mut item = nav::NavItem::new(
1416 format!("resource:{unprefixed}"),
1417 resource.nav_label.clone(),
1418 unprefixed,
1419 )
1420 .order(resource.menu.order);
1421 item.icon = resource.menu.icon.clone();
1422 item.permission = resource.permission.clone();
1423 item.under = resource.menu.under.clone();
1424 item.after = resource.menu.after.clone();
1425 item.before = resource.menu.before.clone();
1426 nav_items.push(item);
1427 }
1428 for reg in &app_screens {
1429 let Some(label) = ®.nav_label else {
1430 continue;
1431 };
1432 if reg.menu.hidden {
1433 continue;
1434 }
1435 let mut item = nav::NavItem::new(
1436 format!("screen:{}", reg.base_path),
1437 label.clone(),
1438 reg.base_path.clone(),
1439 )
1440 .permission(reg.permission.clone())
1441 .order(reg.menu.order);
1442 item.icon = reg.menu.icon.clone();
1443 item.under = reg.menu.under.clone();
1444 item.after = reg.menu.after.clone();
1445 item.before = reg.menu.before.clone();
1446 nav_items.push(item);
1447 }
1448 let nav =
1449 nav::NavTree::build(nav_items, &admin_path).unwrap_or_else(|e| panic!("admin menu: {e}"));
1450 let palette_sources: Vec<palette::RecordSource> = app_resources
1453 .iter()
1454 .filter_map(|r| {
1455 r.palette.as_ref().map(|source| palette::RecordSource {
1456 label: r.nav_label.clone(),
1457 source: source.clone(),
1458 base_path: r.base_path.clone(),
1459 permission: r.permission.clone(),
1460 })
1461 })
1462 .collect();
1463 resources.extend(app_resources);
1464
1465 let app_name = if config.app_name.trim().is_empty() {
1466 "Laterite".to_string()
1467 } else {
1468 config.app_name.clone()
1469 };
1470
1471 let mut pickers = picker::PickerRegistry::new();
1474 for reg in app_picker_sources {
1475 let name = reg.name.clone();
1476 if !field::is_name(&name, true) {
1477 panic!("invalid picker source name `{name}`");
1478 }
1479 if pickers.insert(name.clone(), reg).is_some() {
1480 panic!("duplicate picker source `{name}`");
1481 }
1482 }
1483
1484 for source in &palette_sources {
1488 if !pickers.contains_key(&source.source) {
1489 panic!(
1490 "admin resource `{}` names palette source `{}`, which no module registered",
1491 source.base_path, source.source
1492 );
1493 }
1494 }
1495 let pickers = Arc::new(pickers);
1496 let mut field_types = field::builtin_registry();
1497 field_types.insert(
1498 "reference".to_string(),
1499 Arc::new(field::RefPickerField::new(pickers.clone())),
1500 );
1501
1502 let mut persisters = persist::PersisterRegistry::new();
1505 for reg in app_persisters {
1506 let name = reg.name.clone();
1507 if !field::is_name(&name, true) {
1508 panic!("invalid persister name `{name}`");
1509 }
1510 if persisters.insert(name.clone(), reg.persister).is_some() {
1511 panic!("duplicate persister `{name}`");
1512 }
1513 }
1514
1515 for reg in app_field_types {
1521 let name = reg.name().to_string();
1522 if !field::is_name(&name, true) {
1523 panic!("invalid field type name `{name}`");
1524 }
1525 if field_types.insert(name.clone(), reg.field_type).is_some() {
1526 panic!("duplicate field type `{name}`");
1527 }
1528 }
1529
1530 let mut icon_set = laterite_core::icons::IconSet::new();
1534 for (owner, reg) in app_icons {
1535 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1536 panic!("module `{owner}` contributed an invalid icon: {e}");
1537 }
1538 }
1539
1540 let mut assets = builtin_assets();
1543 assets.insert(
1544 ICON_SPRITE,
1545 AdminAsset {
1546 mime: "image/svg+xml",
1547 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1548 },
1549 );
1550
1551 for item in &settings {
1555 icons::require(
1556 &icon_set,
1557 &format!("settings item `{}`", item.code),
1558 item.icon.as_deref(),
1559 );
1560 }
1561 for node in nav
1562 .top
1563 .iter()
1564 .flat_map(|n| std::iter::once(n).chain(n.children.iter()))
1565 {
1566 icons::require(
1567 &icon_set,
1568 &format!("menu entry `{}`", node.item.key),
1569 node.item.icon.as_deref(),
1570 );
1571 }
1572
1573 let mut column_types = list::builtin_column_registry();
1577 for reg in app_column_types {
1578 let name = reg.name().to_string();
1579 if !field::is_name(&name, true) {
1580 panic!("invalid column type name `{name}`");
1581 }
1582 if column_types.insert(name.clone(), reg.column_type).is_some() {
1583 panic!("duplicate column type `{name}`");
1584 }
1585 }
1586
1587 let settings = Arc::new(settings);
1588 let asset_urls_arc = Arc::new(asset_urls(&assets));
1590 let sprite_url: Arc<str> = Arc::from(
1591 asset_urls_arc
1592 .get(ICON_SPRITE)
1593 .map(|p| format!("{admin_path}/assets/{p}"))
1594 .unwrap_or_default()
1595 .as_str(),
1596 );
1597 let icons_arc = Arc::new(icon_set);
1598 let state = AdminState {
1599 auth,
1600 db,
1601 nav: Arc::new(nav),
1602 settings: settings.clone(),
1603 permissions: Arc::new(permissions),
1604 admin_path: Arc::from(admin_path.as_str()),
1605 secure_cookie: config.secure_cookie,
1606 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1607 icons: icons_arc.clone(),
1608 sprite_url: sprite_url.clone(),
1609 origin: Arc::from(config.origin.trim_end_matches('/')),
1610 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1611 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1612 catalogs,
1613 app_name,
1614 brand_cache: Arc::new(RwLock::new(None)),
1615 field_types: Arc::new(field_types),
1616 column_types: Arc::new(column_types),
1617 plugin_defined,
1618 assets: Arc::new(assets),
1619 asset_urls: asset_urls_arc.clone(),
1620 pickers,
1621 palette: Arc::new(palette_sources),
1622 overrides: Arc::new(field::NoOverrides),
1623 };
1624
1625 let mut protected = Router::new().route(&admin_path, get(dashboard));
1626 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
1630 for resource in &resources {
1631 let (mounted, prepared) = mount_resource(
1632 resource,
1633 &state.field_types,
1634 &state.column_types,
1635 &permission_codes,
1636 &persisters,
1637 &app_listeners,
1638 );
1639 protected = protected.merge(mounted);
1640 if let Some(prepared) = prepared {
1641 let key = resource
1642 .base_path
1643 .strip_prefix(admin_path.as_str())
1644 .unwrap_or(&resource.base_path);
1645 forms.insert(key.to_string(), prepared);
1646 }
1647 }
1648 let forms: form::Forms = Arc::new(forms);
1649 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
1653 .plugin_defined
1654 .items::<laterite_media::DiskReg>()
1655 .into_iter()
1656 .find(|d| d.name == import::DISK)
1657 .map(|d| d.driver.clone())
1658 .unwrap_or_else(|| {
1659 Arc::new(laterite_media::LocalDisk::new(format!(
1660 "{}/{}",
1661 config.storage_dir.trim_end_matches('/'),
1662 import::DISK
1663 )))
1664 });
1665 for resource in &resources {
1666 let Some(link) = &resource.list.import else {
1667 continue;
1668 };
1669 let key = resource
1670 .base_path
1671 .strip_prefix(admin_path.as_str())
1672 .unwrap_or(&resource.base_path)
1673 .to_string();
1674 let prepared = forms.get(&key).unwrap_or_else(|| {
1675 panic!(
1676 "admin resource `{}` declares `import` but has no form to import through",
1677 resource.base_path
1678 )
1679 });
1680 let feature = import::ImportFeature {
1681 disk: private_disk.clone(),
1682 };
1683 let base = link.href.clone();
1684 let ctx = routes::RouteCtx::new(
1685 state.db.clone(),
1686 state.auth.clone(),
1687 state.field_types.clone(),
1688 state.overrides.clone(),
1689 &base,
1690 &admin_path,
1691 &state.origin,
1692 state.plugin_defined.clone(),
1693 state.auth.events().clone(),
1694 forms.clone(),
1695 );
1696 let info = routes::ResourceInfo::new(
1697 key,
1698 resource.base_path.clone(),
1699 resource.nav_label.clone(),
1700 link.permission.clone(),
1701 form::FormHandle::new(state.db.clone(), prepared.clone()),
1702 );
1703 protected = protected.merge(guard_with_permission(
1704 Router::new()
1705 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
1706 &link.permission,
1707 ));
1708 }
1709 for reg in &app_screens {
1713 let base = format!("{admin_path}{}", reg.base_path);
1714 let ctx = routes::RouteCtx::new(
1715 state.db.clone(),
1716 state.auth.clone(),
1717 state.field_types.clone(),
1718 state.overrides.clone(),
1719 &base,
1720 &admin_path,
1721 &state.origin,
1722 state.plugin_defined.clone(),
1723 state.auth.events().clone(),
1724 forms.clone(),
1725 );
1726 protected = protected.merge(guard_with_permission(
1727 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1728 ®.permission,
1729 ));
1730 }
1731
1732 protected = protected.merge(guard_with_permission(
1735 Router::new()
1736 .route(
1737 &format!("{admin_path}/roles/new"),
1738 get(roles::new_form).post(roles::create),
1739 )
1740 .route(
1741 &format!("{admin_path}/roles/{{id}}/edit"),
1742 get(roles::edit_form).post(roles::update),
1743 ),
1744 "backend.manage_roles",
1745 ));
1746 protected = protected.merge(guard_with_permission(
1749 Router::new()
1750 .route(
1751 &format!("{admin_path}/users/new"),
1752 get(users::new_form).post(users::create),
1753 )
1754 .route(
1755 &format!("{admin_path}/users/{{id}}/edit"),
1756 get(users::edit_form).post(users::update),
1757 )
1758 .route(
1759 &format!("{admin_path}/users/{{id}}/active"),
1760 post(users::set_active),
1761 )
1762 .route(
1763 &format!("{admin_path}/users/{{id}}/password"),
1764 post(users::reset_password),
1765 )
1766 .route(
1767 &format!("{admin_path}/users/{{id}}/unlock"),
1768 post(users::unlock),
1769 ),
1770 "backend.manage_users",
1771 ));
1772 protected = protected.merge(guard_with_permission(
1775 Router::new()
1776 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1777 .route(
1778 &format!("{admin_path}/plugins/toggle"),
1779 post(plugins::toggle),
1780 ),
1781 plugins::MANAGE_PERMISSION,
1782 ));
1783 protected = protected
1784 .route(&format!("{admin_path}/settings"), get(settings_index))
1785 .route(
1786 &format!("{admin_path}/preferences"),
1787 get(preferences_form).post(preferences_update),
1788 )
1789 .route(
1790 &format!("{admin_path}/preferences/password"),
1791 post(password_update),
1792 )
1793 .route(
1794 &format!("{admin_path}/preferences/menu"),
1795 post(menu_order_update),
1796 )
1797 .route(&format!("{admin_path}/palette"), get(palette::palette))
1798 .route(
1799 &format!("{admin_path}/preferences/sessions/revoke"),
1800 post(session_revoke),
1801 )
1802 .route(
1803 &format!("{admin_path}/preferences/sessions/revoke-others"),
1804 post(session_revoke_others),
1805 )
1806 .route(
1809 &format!("{admin_path}/pickers/{{source}}/search"),
1810 any(picker::search),
1811 )
1812 .route(
1813 &format!("{admin_path}/pickers/{{source}}/resolve"),
1814 any(picker::resolve),
1815 )
1816 .route(&format!("{admin_path}/logout"), post(logout));
1817
1818 for item in settings.iter().filter(|i| i.link.is_none()) {
1822 let path = format!("{admin_path}{}", item.route());
1823 let code = item.code.clone();
1824 let edit_code = code.clone();
1825 protected = protected.route(
1826 &path,
1827 get(
1828 move |State(state): State<AdminState>,
1829 Extension(shell): Extension<Shell>,
1830 Extension(user): Extension<AuthenticatedUser>| {
1831 let code = edit_code.clone();
1832 async move { settings_edit(state, shell, user, code).await }
1833 },
1834 )
1835 .post(
1836 move |State(state): State<AdminState>,
1837 Extension(shell): Extension<Shell>,
1838 Extension(user): Extension<AuthenticatedUser>,
1839 Extension(session): Extension<session::SessionHandle>,
1840 Form(data): Form<HashMap<String, String>>| {
1841 let code = code.clone();
1842 async move { settings_update(state, shell, user, session, code, data).await }
1843 },
1844 ),
1845 );
1846 }
1847
1848 protected
1849 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1850 .route(
1854 &format!("{admin_path}/login"),
1855 get(login_form).post(login_submit),
1856 )
1857 .route(
1858 &format!("{admin_path}/setup"),
1859 get(setup_form).post(setup_submit),
1860 )
1861 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1862 .merge(mount_public(
1866 &app_public,
1867 &state.db,
1868 &state.auth,
1869 &state.field_types,
1870 &state.overrides,
1871 &admin_path,
1872 &state.origin,
1873 &state.plugin_defined,
1874 state.auth.events(),
1875 &forms,
1876 ))
1877 .fallback(not_found_fallback)
1879 .layer(middleware::from_fn_with_state(
1882 state.clone(),
1883 enforce_origin,
1884 ))
1885 .layer(middleware::from_fn_with_state(
1888 state.clone(),
1889 capture_client,
1890 ))
1891 .layer(
1896 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1897 header::CACHE_CONTROL,
1898 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1899 ),
1900 )
1901 .layer(
1905 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1906 header::VARY,
1907 axum::http::HeaderValue::from_static("HX-Request"),
1908 ),
1909 )
1910 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1911 handle_panic,
1912 ))
1913 .with_state(state)
1914}
1915
1916async fn not_found_fallback(
1919 State(state): State<AdminState>,
1920 method: axum::http::Method,
1921 uri: axum::http::Uri,
1922) -> AdminError {
1923 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
1924 AdminError::NotFound
1925}
1926
1927async fn not_found_plain() -> AdminError {
1929 AdminError::NotFound
1930}
1931
1932fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1934 let cause = err
1935 .downcast_ref::<&str>()
1936 .map(|s| s.to_string())
1937 .or_else(|| err.downcast_ref::<String>().cloned())
1938 .unwrap_or_else(|| "unknown panic".to_string());
1939 tracing::error!(panic = cause, "admin handler panicked");
1940 error::masked_500()
1941}
1942
1943pub fn normalize_path(path: &str) -> String {
1948 let trimmed = path.trim().trim_matches('/');
1949 if trimmed.is_empty() {
1950 "/admin".to_string()
1951 } else {
1952 format!("/{trimmed}")
1953 }
1954}
1955
1956#[allow(clippy::too_many_arguments)]
1960fn mount_public(
1961 regs: &[routes::PublicRouteReg],
1962 db: &Db,
1963 auth: &AuthService,
1964 field_types: &Arc<field::FieldRegistry>,
1965 overrides: &Arc<dyn field::OverrideResolver>,
1966 admin_path: &str,
1967 origin: &str,
1968 plugin_defined: &Arc<laterite_core::Registry>,
1969 events: &laterite_core::Events,
1970 forms: &form::Forms,
1971) -> Router<AdminState> {
1972 let mut router = Router::new();
1973 for reg in regs {
1974 let ctx = routes::RouteCtx::new(
1975 db.clone(),
1976 auth.clone(),
1977 field_types.clone(),
1978 overrides.clone(),
1979 ®.path,
1980 admin_path,
1981 origin,
1982 plugin_defined.clone(),
1983 events.clone(),
1984 forms.clone(),
1985 );
1986 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
1991 }
1992 router
1993}
1994
1995pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1998 let old = resource.base_path.clone();
1999 resource.base_path = base.to_string();
2000 if let Some(edit_base) = &mut resource.list.edit_base {
2001 if edit_base == &old {
2002 *edit_base = base.to_string();
2003 }
2004 }
2005 if let Some(form) = &mut resource.form {
2006 if form.base_path == old {
2007 form.base_path = base.to_string();
2008 }
2009 }
2010}
2011
2012fn prefix_resource(admin_path: &str, resource: &mut Resource) {
2013 resource.base_path = format!("{admin_path}{}", resource.base_path);
2014 if let Some(edit_base) = &mut resource.list.edit_base {
2015 *edit_base = format!("{admin_path}{edit_base}");
2016 }
2017 if let Some(form) = &mut resource.form {
2018 form.base_path = format!("{admin_path}{}", form.base_path);
2019 }
2020}
2021
2022pub(crate) struct AdminAsset {
2024 pub mime: &'static str,
2025 pub bytes: std::borrow::Cow<'static, [u8]>,
2029}
2030
2031pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2034
2035pub(crate) type AssetUrls = HashMap<&'static str, String>;
2039
2040pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2042 registry
2043 .iter()
2044 .map(|(&key, asset)| {
2045 (
2046 key,
2047 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2048 )
2049 })
2050 .collect()
2051}
2052
2053pub(crate) fn page_assets(
2060 keys: &[&str],
2061 base: &str,
2062 registry: &AssetRegistry,
2063 urls: &AssetUrls,
2064) -> Vec<PageAsset> {
2065 let mut seen = std::collections::HashSet::new();
2066 let mut out = Vec::new();
2067 for &key in keys {
2068 if !seen.insert(key) {
2069 continue;
2070 }
2071 match (registry.get(key), urls.get(key)) {
2072 (Some(asset), Some(path)) => out.push(PageAsset {
2073 url: format!("{base}/assets/{path}"),
2074 css: asset.mime.contains("css"),
2075 }),
2076 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2077 }
2078 }
2079 out
2080}
2081
2082pub(crate) fn builtin_assets() -> AssetRegistry {
2091 let font = |bytes: &'static [u8]| AdminAsset {
2092 mime: "font/woff2",
2093 bytes: std::borrow::Cow::Borrowed(bytes),
2094 };
2095 HashMap::from([
2096 (
2097 "laterite.css",
2098 AdminAsset {
2099 mime: "text/css; charset=utf-8",
2100 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2101 },
2102 ),
2103 (
2104 "laterite.js",
2105 AdminAsset {
2106 mime: "text/javascript; charset=utf-8",
2107 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2108 },
2109 ),
2110 (
2111 "vendor/htmx.min.js",
2112 AdminAsset {
2113 mime: "text/javascript; charset=utf-8",
2114 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2115 },
2116 ),
2117 (
2118 "fields/ref-picker.js",
2119 AdminAsset {
2120 mime: "text/javascript; charset=utf-8",
2121 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
2122 },
2123 ),
2124 (
2125 "fields/ref-picker.css",
2126 AdminAsset {
2127 mime: "text/css; charset=utf-8",
2128 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2129 "../assets/fields/ref-picker.css"
2130 )),
2131 },
2132 ),
2133 (
2134 "mark.svg",
2135 AdminAsset {
2136 mime: "image/svg+xml",
2137 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
2138 },
2139 ),
2140 (
2141 "mark.png",
2142 AdminAsset {
2143 mime: "image/png",
2144 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
2145 },
2146 ),
2147 (
2148 "fonts/space-grotesk-500.woff2",
2149 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
2150 ),
2151 (
2152 "fonts/space-grotesk-600.woff2",
2153 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
2154 ),
2155 (
2156 "fonts/space-grotesk-700.woff2",
2157 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
2158 ),
2159 (
2160 "fonts/ibm-plex-sans-400.woff2",
2161 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
2162 ),
2163 (
2164 "fonts/ibm-plex-sans-600.woff2",
2165 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
2166 ),
2167 (
2168 "fonts/ibm-plex-mono-400.woff2",
2169 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
2170 ),
2171 (
2172 "fonts/ibm-plex-mono-600.woff2",
2173 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
2174 ),
2175 ])
2176}
2177
2178async fn serve_asset(
2180 State(state): State<AdminState>,
2181 headers: HeaderMap,
2182 Path(path): Path<String>,
2183) -> Response {
2184 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
2188 let Some(asset) = state.assets.get(key.as_str()) else {
2189 return not_found();
2190 };
2191 let policy = if fingerprinted {
2192 http_cache::IMMUTABLE
2193 } else {
2194 http_cache::REVALIDATE
2195 };
2196 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
2197}
2198
2199fn mount_resource(
2204 resource: &Resource,
2205 field_types: &field::FieldRegistry,
2206 column_types: &list::ColumnRegistry,
2207 permission_codes: &std::collections::HashSet<String>,
2208 persisters: &persist::PersisterRegistry,
2209 listeners: &[laterite_core::ModelListenerReg],
2210) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2211 list::check(&resource.list, column_types, permission_codes)
2215 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2216 if resource.form.is_some() && resource.permission.is_none() {
2221 panic!(
2222 "admin resource `{}` has a create/edit form but no permission; a write \
2223 resource must declare a permission",
2224 resource.base_path
2225 );
2226 }
2227 let base = resource.base_path.clone();
2228 let list_path = base.clone();
2229 let mut list_cfg = resource.list.clone();
2230 if resource.list.columns.iter().any(|c| c.editable) {
2233 let Some(form_cfg) = resource.form.clone() else {
2234 panic!(
2235 "admin resource `{}` has an editable column but no form to edit through",
2236 resource.base_path
2237 );
2238 };
2239 for column in resource.list.columns.iter().filter(|c| c.editable) {
2240 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2241 panic!(
2242 "admin resource `{}`: column `{}` is editable but the form has no such field",
2243 resource.base_path, column.field
2244 );
2245 }
2246 }
2247 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2248 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2249 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2250 }
2251 let cell_cfg = list_cfg.clone();
2252 let mut router = Router::new().route(
2253 &base,
2254 get(
2255 move |State(state): State<AdminState>,
2256 Extension(shell): Extension<Shell>,
2257 Query(params): Query<list::ListParams>,
2258 Query(raw): Query<std::collections::HashMap<String, String>>,
2259 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2260 Extension(user): Extension<AuthenticatedUser>,
2261 Extension(session): Extension<session::SessionHandle>,
2262 headers: axum::http::HeaderMap| {
2263 let cfg = list_cfg.clone();
2264 let path = list_path.clone();
2265 async move {
2266 list::handle_remembering(
2267 &state,
2268 &cfg,
2269 &path,
2270 params,
2271 &raw,
2272 &user,
2273 shell,
2274 &headers,
2275 query_string.as_deref(),
2276 Some(&session),
2277 )
2278 .await
2279 }
2280 },
2281 ),
2282 );
2283
2284 if cell_cfg.editor.is_some() {
2285 let (cfg, list_path) = (cell_cfg, base.clone());
2286 router = router.route(
2287 &format!("{base}/{{id}}/cell"),
2288 post(
2289 move |State(state): State<AdminState>,
2290 Extension(shell): Extension<Shell>,
2291 Extension(user): Extension<AuthenticatedUser>,
2292 Extension(session): Extension<session::SessionHandle>,
2293 Path(id): Path<String>,
2294 headers: axum::http::HeaderMap,
2295 Form(data): Form<HashMap<String, String>>| {
2296 let (cfg, path) = (cfg.clone(), list_path.clone());
2297 async move {
2298 list::cell_handler(
2299 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2300 )
2301 .await
2302 }
2303 },
2304 ),
2305 );
2306 }
2307
2308 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2311 if resource.list.exportable {
2312 router = router.route(
2313 &format!("{base}/export"),
2314 get(
2315 move |state: State<AdminState>,
2316 Query(raw): Query<std::collections::HashMap<String, String>>,
2317 Extension(user): Extension<AuthenticatedUser>,
2318 Extension(shell): Extension<Shell>,
2319 Extension(session): Extension<session::SessionHandle>| {
2320 let cfg = export_cfg.clone();
2321 let path = export_path.clone();
2322 async move {
2323 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2324 }
2325 },
2326 ),
2327 );
2328 }
2329
2330 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2331 router = router.route(
2332 &format!("{base}/columns"),
2333 post(
2334 move |State(state): State<AdminState>,
2335 Extension(user): Extension<AuthenticatedUser>,
2336 Extension(session): Extension<session::SessionHandle>,
2337 headers: axum::http::HeaderMap,
2338 Form(pairs): Form<Vec<(String, String)>>| {
2339 let cfg = columns_cfg.clone();
2340 let path = columns_path.clone();
2341 async move {
2342 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2343 }
2344 },
2345 ),
2346 );
2347
2348 if resource.list.deletable {
2349 let persister: Arc<dyn persist::Persister> =
2353 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
2354 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
2355 panic!(
2356 "admin resource `{}`: unregistered persister `{key}`",
2357 resource.base_path
2358 )
2359 }),
2360 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
2361 };
2362 let ctx = Arc::new(bulk::BulkContext {
2363 base_path: resource.base_path.clone(),
2364 entity: resource.list.entity.clone(),
2365 persister,
2366 listeners: listeners
2367 .iter()
2368 .filter(|reg| reg.matches(&resource.list.entity))
2369 .map(|reg| reg.listener.clone())
2370 .collect(),
2371 });
2372 router =
2373 router.route(
2374 &format!("{base}/delete"),
2375 post(
2376 move |state: State<AdminState>,
2377 user: Extension<AuthenticatedUser>,
2378 session: Extension<session::SessionHandle>,
2379 headers: axum::http::HeaderMap,
2380 form: Form<Vec<(String, String)>>| {
2381 let ctx = ctx.clone();
2382 async move {
2383 bulk::delete_handler(state, user, session, ctx, headers, form).await
2384 }
2385 },
2386 ),
2387 );
2388 }
2389
2390 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
2393 let tree = resource.list.tree.clone().unwrap_or_default();
2394 let mut move_cfg = form::FormConfig::new(
2395 resource.list.entity.clone(),
2396 laterite_core::t!("Move to"),
2397 base.clone(),
2398 resource.list.id_field.clone(),
2399 vec![form::FormField::reference(
2400 &tree.parent_key,
2401 laterite_core::t!("New parent"),
2402 &source,
2403 )
2404 .help(laterite_core::t!("Leave empty for the top level."))],
2405 );
2406 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
2407 move_cfg = move_cfg.persist(persist);
2408 }
2409 move_cfg.done_path = Some(base.clone());
2410 let move_form = Arc::new(
2411 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
2412 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
2413 );
2414 let (cfg, list_path) = (resource.list.clone(), base.clone());
2415 let (form_get, form_post) = (move_form.clone(), move_form);
2416 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
2417 router = router.route(
2418 &format!("{base}/{{id}}/move"),
2419 get(
2420 move |State(state): State<AdminState>,
2421 Extension(shell): Extension<Shell>,
2422 Path(id): Path<String>| {
2423 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
2424 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
2425 },
2426 )
2427 .post(
2428 move |State(state): State<AdminState>,
2429 Extension(shell): Extension<Shell>,
2430 Extension(user): Extension<AuthenticatedUser>,
2431 Extension(session): Extension<session::SessionHandle>,
2432 Path(id): Path<String>,
2433 headers: axum::http::HeaderMap,
2434 Form(data): Form<HashMap<String, String>>| {
2435 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
2436 async move {
2437 list::move_record(
2438 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
2439 )
2440 .await
2441 }
2442 },
2443 ),
2444 );
2445 }
2446
2447 if resource.list.reorder.is_some() {
2448 let (cfg, list_path) = (resource.list.clone(), base.clone());
2449 router = router.route(
2450 &format!("{base}/reorder"),
2451 post(
2452 move |State(state): State<AdminState>,
2453 Extension(shell): Extension<Shell>,
2454 Extension(user): Extension<AuthenticatedUser>,
2455 Extension(session): Extension<session::SessionHandle>,
2456 headers: axum::http::HeaderMap,
2457 Form(pairs): Form<Vec<(String, String)>>| {
2458 let cfg = cfg.clone();
2459 let path = list_path.clone();
2460 async move {
2461 list::reorder_handler(
2462 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
2463 &headers, &pairs,
2464 )
2465 .await
2466 }
2467 },
2468 ),
2469 );
2470 }
2471
2472 if !resource.relations.is_empty() && resource.form.is_none() {
2475 panic!(
2476 "admin resource `{}` declares relations but has no form to show them on",
2477 resource.base_path
2478 );
2479 }
2480 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
2481 for rel in &resource.relations {
2482 let prepared = relation::PreparedRelation::prepare(
2483 resource,
2484 rel.clone(),
2485 field_types,
2486 column_types,
2487 permission_codes,
2488 persisters,
2489 listeners,
2490 )
2491 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2492 relations.push(Arc::new(prepared));
2493 }
2494 let relations = Arc::new(relations);
2495
2496 let mut prepared_form = None;
2497 if let Some(form_cfg) = resource.form.clone() {
2498 let mut prepared =
2501 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2502 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2503 prepared.stay = !relations.is_empty();
2505 let prepared = Arc::new(prepared);
2506 prepared_form = Some(prepared.clone());
2507 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
2508 let relations_for_new = relations.clone();
2509 router = router.route(
2510 &format!("{base}/new"),
2511 get(
2512 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
2513 let pf = new_pf.clone();
2514 let relations = relations_for_new.clone();
2515 async move {
2516 let pending = relations
2517 .iter()
2518 .map(|r| shell.tt(&r.config.title))
2519 .collect();
2520 form::new_form(&state, &pf, shell, pending)
2521 }
2522 },
2523 )
2524 .post(
2525 move |State(state): State<AdminState>,
2526 Extension(shell): Extension<Shell>,
2527 Extension(user): Extension<AuthenticatedUser>,
2528 Extension(session): Extension<session::SessionHandle>,
2529 headers: axum::http::HeaderMap,
2530 Form(data): Form<HashMap<String, String>>| {
2531 let pf = create_pf.clone();
2532 async move {
2533 form::create(&state, &pf, data, shell, &user, &session, &headers).await
2534 }
2535 },
2536 ),
2537 );
2538
2539 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
2540 let relations_for_edit = relations.clone();
2541 router = router.route(
2542 &format!("{base}/{{id}}/edit"),
2543 get(
2544 move |State(state): State<AdminState>,
2545 Extension(shell): Extension<Shell>,
2546 Extension(user): Extension<AuthenticatedUser>,
2547 Path(id): Path<String>| {
2548 let pf = edit_pf.clone();
2549 let relations = relations_for_edit.clone();
2550 async move {
2551 let mut sections = Vec::new();
2552 for rel in relations.iter() {
2553 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
2554 sections.push(section);
2555 }
2556 }
2557 form::edit_form(&state, &pf, id, shell, sections).await
2558 }
2559 },
2560 )
2561 .post(
2562 move |State(state): State<AdminState>,
2563 Extension(shell): Extension<Shell>,
2564 Extension(user): Extension<AuthenticatedUser>,
2565 Extension(session): Extension<session::SessionHandle>,
2566 Path(id): Path<String>,
2567 headers: axum::http::HeaderMap,
2568 Form(data): Form<HashMap<String, String>>| {
2569 let pf = update_pf.clone();
2570 async move {
2571 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
2572 }
2573 },
2574 ),
2575 );
2576 }
2577
2578 for rel in relations.iter() {
2579 let name = rel.config.name.clone();
2580 let mut routes: Router<AdminState> = Router::new();
2581 let r = rel.clone();
2582 routes =
2583 routes.route(
2584 &format!("{base}/{{id}}/{name}"),
2585 get(
2586 move |state: State<AdminState>,
2587 shell: Extension<Shell>,
2588 user: Extension<AuthenticatedUser>,
2589 id: Path<String>,
2590 params: Query<list::ListParams>,
2591 raw: Query<HashMap<String, String>>,
2592 headers: axum::http::HeaderMap| {
2593 let r = r.clone();
2594 async move {
2595 relation::index(state, shell, user, id, params, raw, headers, r).await
2596 }
2597 },
2598 ),
2599 );
2600 let (r_new, r_create) = (rel.clone(), rel.clone());
2601 routes = routes.route(
2602 &format!("{base}/{{id}}/{name}/new"),
2603 get(
2604 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
2605 let r = r_new.clone();
2606 async move { relation::new_form(state, shell, id, r).await }
2607 },
2608 )
2609 .post(
2610 move |state: State<AdminState>,
2611 shell: Extension<Shell>,
2612 user: Extension<AuthenticatedUser>,
2613 session: Extension<session::SessionHandle>,
2614 id: Path<String>,
2615 headers: axum::http::HeaderMap,
2616 data: Form<HashMap<String, String>>| {
2617 let r = r_create.clone();
2618 async move {
2619 relation::create(state, shell, user, session, id, headers, data, r).await
2620 }
2621 },
2622 ),
2623 );
2624 let (r_edit, r_update) = (rel.clone(), rel.clone());
2625 routes = routes.route(
2626 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
2627 get(
2628 move |state: State<AdminState>,
2629 shell: Extension<Shell>,
2630 ids: Path<(String, String)>| {
2631 let r = r_edit.clone();
2632 async move { relation::edit_form(state, shell, ids, r).await }
2633 },
2634 )
2635 .post(
2636 move |state: State<AdminState>,
2637 shell: Extension<Shell>,
2638 user: Extension<AuthenticatedUser>,
2639 session: Extension<session::SessionHandle>,
2640 ids: Path<(String, String)>,
2641 headers: axum::http::HeaderMap,
2642 data: Form<HashMap<String, String>>| {
2643 let r = r_update.clone();
2644 async move {
2645 relation::update(state, shell, user, session, ids, headers, data, r).await
2646 }
2647 },
2648 ),
2649 );
2650 let r_delete = rel.clone();
2651 routes = routes.route(
2652 &format!("{base}/{{id}}/{name}/delete"),
2653 post(
2654 move |state: State<AdminState>,
2655 shell: Extension<Shell>,
2656 user: Extension<AuthenticatedUser>,
2657 session: Extension<session::SessionHandle>,
2658 id: Path<String>,
2659 headers: axum::http::HeaderMap,
2660 pairs: Form<Vec<(String, String)>>| {
2661 let r = r_delete.clone();
2662 async move {
2663 relation::delete(state, shell, user, session, id, headers, pairs, r).await
2664 }
2665 },
2666 ),
2667 );
2668 if rel.reorderable() {
2669 let r_reorder = rel.clone();
2670 routes = routes.route(
2671 &format!("{base}/{{id}}/{name}/reorder"),
2672 post(
2673 move |state: State<AdminState>,
2674 shell: Extension<Shell>,
2675 user: Extension<AuthenticatedUser>,
2676 session: Extension<session::SessionHandle>,
2677 id: Path<String>,
2678 headers: axum::http::HeaderMap,
2679 pairs: Form<Vec<(String, String)>>| {
2680 let r = r_reorder.clone();
2681 async move {
2682 relation::reorder(state, shell, user, session, id, headers, pairs, r)
2683 .await
2684 }
2685 },
2686 ),
2687 );
2688 }
2689 if let Some(permission) = &rel.config.permission {
2690 routes = guard_with_permission(routes, permission);
2691 }
2692 router = router.merge(routes);
2693 }
2694
2695 if let Some(permission) = &resource.permission {
2697 router = guard_with_permission(router, permission);
2698 }
2699 (router, prepared_form)
2700}
2701
2702fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
2707 let needed: Arc<str> = Arc::from(permission);
2708 router.route_layer(middleware::from_fn(
2709 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
2710 let needed = needed.clone();
2711 async move {
2712 if user.allows(&needed) {
2713 next.run(req).await
2714 } else {
2715 forbidden()
2716 }
2717 }
2718 },
2719 ))
2720}
2721
2722async fn require_auth(
2728 State(state): State<AdminState>,
2729 jar: CookieJar,
2730 request: Request,
2731 next: Next,
2732) -> Response {
2733 let login = format!("{}/login", state.admin_path);
2734 let client_ctx = request
2735 .extensions()
2736 .get::<RequestContext>()
2737 .cloned()
2738 .unwrap_or_default();
2739 let mut ended: Option<laterite_auth::SessionEnd> = None;
2744 let live = match jar.get(SESSION_COOKIE) {
2745 Some(cookie) => {
2746 let token = cookie.value().to_string();
2747 match state.auth.resolve_session(&token).await {
2748 Ok(resolved) => Some((token, resolved)),
2749 Err(_) => {
2750 ended = state.auth.session_end(&token).await.ok().flatten();
2751 None
2752 }
2753 }
2754 }
2755 None => None,
2756 };
2757 let htmx = request.headers().contains_key("hx-request");
2758 let (token, resolved, recalled) = match live {
2759 Some((token, resolved)) => (token, resolved, None),
2760 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2763 return signed_out(&state, jar, &login, ended, htmx);
2764 }
2765 None => match recall(&state, &jar, &client_ctx).await {
2766 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2767 None => {
2768 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2772 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2773 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2774 }
2775 }
2776 return signed_out(&state, jar, &login, ended, htmx);
2777 }
2778 },
2779 };
2780 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2781
2782 let mut request = request;
2787 let safe = session::is_safe_method(request.method());
2788 let mut deferred: Option<session::CsrfPending> = None;
2790 if !safe {
2791 let (parts, body) = request.into_parts();
2792 let (body, submitted) = if session::is_multipart(&parts.headers) {
2799 let token = session::header_token(&parts.headers)
2800 .or_else(|| session::query_token(parts.uri.query()));
2801 if token.is_none() {
2806 deferred = Some(session::CsrfPending::default());
2807 }
2808 (body, token)
2809 } else {
2810 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2811 .await
2812 .unwrap_or_default();
2813 let token = session::submitted_token(&parts.headers, &bytes);
2814 (Body::from(bytes), token)
2815 };
2816 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2817 {
2818 tracing::warn!(
2819 user_id = resolved.identity.user.id,
2820 "admin CSRF check failed"
2821 );
2822 return error::csrf_rejected();
2823 }
2824 request = Request::from_parts(parts, body);
2825 }
2826
2827 let user = resolved.identity;
2828 let acting_user_id = user.user.id;
2830 let path = request.uri().path().to_string();
2831 let preferences = format!("{}/preferences", state.admin_path);
2834 if !path.starts_with(&preferences)
2835 && path != format!("{}/logout", state.admin_path)
2836 && state
2837 .auth
2838 .must_change_password(user.user.id)
2839 .await
2840 .unwrap_or(false)
2841 {
2842 let to = format!("{preferences}#password");
2843 let response = if htmx {
2844 (
2845 [("HX-Redirect", to.as_str())],
2846 axum::http::StatusCode::NO_CONTENT,
2847 )
2848 .into_response()
2849 } else {
2850 Redirect::to(&to).into_response()
2851 };
2852 return with_recall_cookies(response, recalled, jar, token, &state);
2853 }
2854 let flash = if safe {
2857 handle.take_flash()
2858 } else {
2859 Vec::new()
2860 };
2861 let accept_language = request
2865 .headers()
2866 .get("accept-language")
2867 .and_then(|v| v.to_str().ok());
2868 let serveable = state.catalogs.locales();
2869 let chain = resolve_locale_chain(
2870 user.user.locale.as_deref(),
2871 accept_language,
2872 &state.default_locale,
2873 &serveable,
2874 );
2875 let i18n = Translator::with_chain(chain, state.catalogs.clone());
2876 let translator = i18n.clone();
2879 let own_order =
2881 laterite_auth::store::user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
2882 .await
2883 .unwrap_or_default();
2884 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
2885 &state.nav,
2886 &state.settings,
2887 &state.admin_path,
2888 &user.permissions,
2889 own_order.as_deref(),
2890 &path,
2891 &i18n,
2892 icons::Icons::new(&state.icons, &state.sprite_url),
2893 );
2894 let brand = state.brand().await;
2895 let shell = Shell::new(
2896 &state.admin_path,
2897 brand,
2898 &visible_nav,
2899 &user,
2900 state.timezone,
2901 sidebar,
2902 active_nav.as_deref(),
2903 handle.csrf_token(),
2904 flash,
2905 i18n,
2906 state.asset_urls.clone(),
2907 state.origin.to_string(),
2908 state.icons.clone(),
2909 state.sprite_url.clone(),
2910 );
2911 request.extensions_mut().insert(user);
2912 request.extensions_mut().insert(shell);
2913 request.extensions_mut().insert(handle.clone());
2914 if let Some(pending) = &deferred {
2916 request.extensions_mut().insert(pending.clone());
2917 }
2918 let mut response = next.run(request).await;
2919
2920 if let Some(pending) = &deferred {
2925 if !pending.was_satisfied() {
2926 tracing::error!(
2927 user_id = acting_user_id,
2928 "a multipart route returned without verifying its request token"
2929 );
2930 return error::csrf_rejected();
2931 }
2932 }
2933
2934 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2938 response = error::localized_error(meta.kind, &translator);
2939 }
2940
2941 if let Some(blob) = handle.dirty_blob() {
2944 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2945 tracing::error!(error = %e, "persisting admin session failed");
2946 }
2947 }
2948 with_recall_cookies(response, recalled, jar, token, &state)
2949}
2950
2951fn with_recall_cookies(
2955 response: Response,
2956 recalled: Option<laterite_auth::RememberCredential>,
2957 jar: CookieJar,
2958 token: String,
2959 state: &AdminState,
2960) -> Response {
2961 match recalled {
2962 Some(credential) => {
2963 let jar = jar
2964 .add(session_cookie(
2965 token,
2966 &state.admin_path,
2967 state.secure_cookie,
2968 ))
2969 .add(remember_cookie(
2970 credential,
2971 &state.admin_path,
2972 state.secure_cookie,
2973 ));
2974 (jar, response).into_response()
2975 }
2976 None => response,
2977 }
2978}
2979
2980async fn recall(
2984 state: &AdminState,
2985 jar: &CookieJar,
2986 ctx: &RequestContext,
2987) -> Option<(
2988 String,
2989 laterite_auth::ResolvedSession,
2990 laterite_auth::RememberCredential,
2991)> {
2992 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
2993 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
2994 let token = recalled.session.token.clone();
2995 let resolved = state.auth.resolve_session(&token).await.ok()?;
2996 Some((token, resolved, recalled.remember))
2997}
2998
2999const MAX_FORM_BYTES: usize = 1024 * 1024;
3002
3003async fn capture_client(
3011 State(state): State<AdminState>,
3012 mut request: Request,
3013 next: Next,
3014) -> Response {
3015 let peer = request
3016 .extensions()
3017 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3018 .map(|info| info.0);
3019 let headers = request.headers();
3020 let ctx = RequestContext {
3021 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3022 user_agent: clientip::user_agent(headers),
3023 };
3024 request.extensions_mut().insert(ctx);
3025 next.run(request).await
3026}
3027
3028async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3029 if !session::is_safe_method(request.method())
3030 && !session::origin_ok(request.headers(), &state.origin)
3031 {
3032 tracing::warn!(
3033 path = request.uri().path(),
3034 "admin CSRF origin check failed"
3035 );
3036 return error::csrf_rejected();
3037 }
3038 next.run(request).await
3039}
3040
3041#[derive(Deserialize)]
3042struct LoginQuery {
3043 ended: Option<String>,
3044}
3045
3046async fn login_form(
3047 State(state): State<AdminState>,
3048 headers: HeaderMap,
3049 Query(query): Query<LoginQuery>,
3050) -> Response {
3051 match state.auth.has_any_operator().await {
3053 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
3054 Ok(true) => {
3055 let i18n = pre_auth_translator(&state, &headers);
3056 let notice = query
3057 .ended
3058 .as_deref()
3059 .and_then(end_notice)
3060 .map(|text| i18n.t(&text));
3061 render(LoginTemplate {
3062 base: state.admin_path.to_string(),
3063 asset_urls: state.asset_urls.clone(),
3064 brand: state.brand().await,
3065 error: None,
3066 notice,
3067 i18n,
3068 })
3069 }
3070 Err(_) => render_error(),
3071 }
3072}
3073
3074#[derive(Deserialize)]
3075struct LoginForm {
3076 username: String,
3077 password: String,
3078 #[serde(default)]
3080 remember: Option<String>,
3081}
3082
3083async fn login_submit(
3084 State(state): State<AdminState>,
3085 jar: CookieJar,
3086 headers: HeaderMap,
3087 Extension(ctx): Extension<RequestContext>,
3088 Form(form): Form<LoginForm>,
3089) -> Response {
3090 match state
3091 .auth
3092 .authenticate(&form.username, &form.password, &ctx)
3093 .await
3094 {
3095 Ok(session) => {
3096 let wants_remember = form.remember.is_some();
3097 let user_id = session.user_id;
3098 let mut jar = jar.add(session_cookie(
3099 session.token,
3100 &state.admin_path,
3101 state.secure_cookie,
3102 ));
3103 if wants_remember {
3104 match state.auth.issue_remember(user_id).await {
3105 Ok(credential) => {
3106 jar = jar.add(remember_cookie(
3107 credential,
3108 &state.admin_path,
3109 state.secure_cookie,
3110 ));
3111 }
3112 Err(e) => {
3114 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
3115 }
3116 }
3117 }
3118 (jar, Redirect::to(&state.admin_path)).into_response()
3119 }
3120 Err(_) => {
3121 let i18n = pre_auth_translator(&state, &headers);
3122 render(LoginTemplate {
3123 base: state.admin_path.to_string(),
3124 asset_urls: state.asset_urls.clone(),
3125 brand: state.brand().await,
3126 error: Some(i18n.t(&t!("Invalid username or password."))),
3127 notice: None,
3128 i18n,
3129 })
3130 }
3131 }
3132}
3133
3134fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
3143 Cookie::build((SESSION_COOKIE, token))
3144 .path(admin_path.to_string())
3145 .http_only(true)
3146 .secure(secure)
3147 .same_site(SameSite::Lax)
3148 .build()
3149}
3150
3151fn remember_cookie(
3154 credential: laterite_auth::RememberCredential,
3155 admin_path: &str,
3156 secure: bool,
3157) -> Cookie<'static> {
3158 let seconds = (credential.expires_at - chrono::Utc::now())
3159 .num_seconds()
3160 .max(0);
3161 Cookie::build((REMEMBER_COOKIE, credential.cookie))
3162 .path(admin_path.to_string())
3163 .http_only(true)
3164 .secure(secure)
3165 .same_site(SameSite::Lax)
3166 .max_age(cookie::time::Duration::seconds(seconds))
3167 .build()
3168}
3169
3170fn signed_out(
3177 state: &AdminState,
3178 jar: CookieJar,
3179 login: &str,
3180 ended: Option<laterite_auth::SessionEnd>,
3181 htmx: bool,
3182) -> Response {
3183 let to = match ended.map(laterite_auth::SessionEnd::code) {
3184 Some(code) => format!("{login}?ended={code}"),
3185 None => login.to_string(),
3186 };
3187 let jar = jar
3188 .remove(
3189 Cookie::build((SESSION_COOKIE, ""))
3190 .path(state.admin_path.to_string())
3191 .build(),
3192 )
3193 .remove(remember_removal(&state.admin_path));
3194 if htmx {
3195 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
3196 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
3197 response.headers_mut().insert("hx-redirect", value);
3198 }
3199 return response;
3200 }
3201 (jar, Redirect::to(&to)).into_response()
3202}
3203
3204fn end_notice(code: &str) -> Option<Text> {
3207 Some(match code {
3208 "password_changed" => {
3209 t!("Your password was changed, so you were signed out. Sign in with the new one.")
3210 }
3211 "deactivated" => t!("This account was deactivated."),
3212 "signed_out_elsewhere" => t!("You were signed out from another device."),
3213 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3214 _ => return None,
3215 })
3216}
3217
3218fn remember_removal(admin_path: &str) -> Cookie<'static> {
3220 Cookie::build((REMEMBER_COOKIE, ""))
3221 .path(admin_path.to_string())
3222 .build()
3223}
3224
3225#[derive(Deserialize)]
3226struct SetupForm {
3227 username: String,
3228 first_name: String,
3229 last_name: String,
3230 email: String,
3231 password: String,
3232 timezone: String,
3233}
3234
3235async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3238 match state.auth.has_any_operator().await {
3239 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3240 Ok(false) => render(setup_view(
3241 &state.admin_path,
3242 state.brand().await,
3243 state.timezone,
3244 None,
3245 pre_auth_translator(&state, &headers),
3246 state.asset_urls.clone(),
3247 state.auth.password_policy().min_length,
3248 )),
3249 Err(_) => render_error(),
3250 }
3251}
3252
3253async fn setup_submit(
3254 State(state): State<AdminState>,
3255 jar: CookieJar,
3256 headers: HeaderMap,
3257 client: Option<Extension<RequestContext>>,
3258 Form(form): Form<SetupForm>,
3259) -> Response {
3260 match state.auth.has_any_operator().await {
3262 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3263 Ok(false) => {}
3264 Err(_) => return render_error(),
3265 }
3266
3267 let username = form.username.trim();
3268 let email = form.email.trim();
3269 let first_name = form.first_name.trim();
3270 let last_name = form.last_name.trim();
3271 let tz = form.timezone.trim();
3272 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3273 {
3274 return render(setup_view(
3275 &state.admin_path,
3276 state.brand().await,
3277 state.timezone,
3278 Some(t!(
3279 "Username, first name, email, and password are all required."
3280 )),
3281 pre_auth_translator(&state, &headers),
3282 state.asset_urls.clone(),
3283 state.auth.password_policy().min_length,
3284 ));
3285 }
3286 if tz.parse::<Tz>().is_err() {
3288 return render(setup_view(
3289 &state.admin_path,
3290 state.brand().await,
3291 state.timezone,
3292 Some(t!("That is not a recognised timezone.")),
3293 pre_auth_translator(&state, &headers),
3294 state.asset_urls.clone(),
3295 state.auth.password_policy().min_length,
3296 ));
3297 }
3298
3299 let min_length = state.auth.password_policy().min_length;
3300 if form.password.chars().count() < min_length {
3301 return render(setup_view(
3302 &state.admin_path,
3303 state.brand().await,
3304 state.timezone,
3305 Some(t!(
3306 "A password needs at least {n} characters.",
3307 n = min_length as i64
3308 )),
3309 pre_auth_translator(&state, &headers),
3310 state.asset_urls.clone(),
3311 state.auth.password_policy().min_length,
3312 ));
3313 }
3314 let new = NewOperator {
3315 username,
3316 email,
3317 first_name,
3318 last_name: (!last_name.is_empty()).then_some(last_name),
3319 password: &form.password,
3320 timezone: Some(tz),
3321 };
3322 let id = match state.auth.create_superuser(new).await {
3323 Ok(id) => id,
3324 Err(_) => {
3325 return render(setup_view(
3326 &state.admin_path,
3327 state.brand().await,
3328 state.timezone,
3329 Some(t!(
3330 "Could not create the account. The username or email may already be taken."
3331 )),
3332 pre_auth_translator(&state, &headers),
3333 state.asset_urls.clone(),
3334 state.auth.password_policy().min_length,
3335 ));
3336 }
3337 };
3338 let actor = laterite_core::Actor::system("first-run setup");
3341 let target = id.to_string();
3342 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
3343 "backend_user",
3344 &target,
3345 Some(username),
3346 );
3347 if let Err(e) = state.auth.record_audit(entry).await {
3348 tracing::error!(error = %e, "failed to write audit log entry");
3349 }
3350
3351 match state
3353 .auth
3354 .authenticate(
3355 username,
3356 &form.password,
3357 &client.map(|c| c.0).unwrap_or_default(),
3358 )
3359 .await
3360 {
3361 Ok(session) => {
3362 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
3363 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
3364 }
3365 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3366 }
3367}
3368
3369fn setup_view(
3372 admin_path: &str,
3373 brand: String,
3374 default_tz: Tz,
3375 error: Option<Text>,
3376 i18n: Translator,
3377 asset_urls: Arc<AssetUrls>,
3378 min_password: usize,
3379) -> SetupTemplate {
3380 let default_name = default_tz.name();
3381 let zones = TZ_VARIANTS
3382 .iter()
3383 .filter(|tz| zone_offered(tz.name()))
3384 .map(|tz| TzOption {
3385 name: tz.name().to_string(),
3386 selected: tz.name() == default_name,
3387 })
3388 .collect();
3389 SetupTemplate {
3390 base: admin_path.to_string(),
3391 asset_urls,
3392 brand,
3393 zones,
3394 error: error.map(|e| i18n.t(&e)),
3395 min_password: min_password as i64,
3396 i18n,
3397 }
3398}
3399
3400async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
3401 if let Some(cookie) = jar.get(SESSION_COOKIE) {
3402 let _ = state.auth.logout(cookie.value()).await;
3403 }
3404 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3407 let _ = state.auth.revoke_remember(cookie.value()).await;
3408 }
3409 let removal = Cookie::build((SESSION_COOKIE, ""))
3410 .path(state.admin_path.to_string())
3411 .build();
3412 (
3413 jar.remove(removal)
3414 .remove(remember_removal(&state.admin_path)),
3415 Redirect::to(&format!("{}/login", state.admin_path)),
3416 )
3417 .into_response()
3418}
3419
3420async fn dashboard(
3421 Extension(shell): Extension<Shell>,
3422 Extension(user): Extension<AuthenticatedUser>,
3423) -> Response {
3424 render(DashboardTemplate {
3425 username: user.user.username,
3426 shell,
3427 })
3428}
3429
3430fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
3433 match &item.permission {
3434 None => true,
3435 Some(p) => perms.allows(p),
3436 }
3437}
3438
3439fn visible_settings(
3443 items: &[settings::SettingsItem],
3444 perms: &PermissionSet,
3445) -> Vec<settings::SettingsItem> {
3446 items
3447 .iter()
3448 .filter(|item| operator_can_see(item, perms))
3449 .cloned()
3450 .collect()
3451}
3452
3453async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
3454 settings::index(shell)
3455}
3456
3457async fn settings_edit(
3458 state: AdminState,
3459 shell: Shell,
3460 user: AuthenticatedUser,
3461 code: String,
3462) -> Response {
3463 let items = visible_settings(&state.settings, &user.permissions);
3466 match items
3467 .iter()
3468 .find(|item| item.code == code && item.link.is_none())
3469 {
3470 Some(item) => settings::edit_form(&state, item, shell, &user).await,
3471 None => not_found(),
3472 }
3473}
3474
3475async fn settings_update(
3476 state: AdminState,
3477 shell: Shell,
3478 user: AuthenticatedUser,
3479 session: session::SessionHandle,
3480 code: String,
3481 data: HashMap<String, String>,
3482) -> Response {
3483 let items = visible_settings(&state.settings, &user.permissions);
3484 match items
3485 .iter()
3486 .find(|item| item.code == code && item.link.is_none())
3487 {
3488 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
3489 None => not_found(),
3490 }
3491}
3492
3493async fn preferences_form(
3495 State(state): State<AdminState>,
3496 Extension(shell): Extension<Shell>,
3497 Extension(user): Extension<AuthenticatedUser>,
3498 jar: CookieJar,
3499) -> Response {
3500 let account = account_security(&state, &user, &shell, &jar).await;
3501 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
3502 .into_iter()
3503 .filter(|i| i.per_operator)
3504 .map(|i| PreferenceLink {
3505 label: shell.tt(&i.label),
3506 description: shell.tt(&i.description),
3507 href: i.path(&state.admin_path),
3508 })
3509 .collect();
3510 render(preferences_view(
3511 &shell,
3512 &user,
3513 state.timezone,
3514 &state.default_locale,
3515 &offered_locales(&state.catalogs),
3516 account,
3517 state.auth.password_policy().min_length,
3518 items,
3519 ))
3520}
3521
3522struct PreferenceLink {
3524 label: String,
3525 description: String,
3526 href: String,
3527}
3528
3529#[derive(Deserialize)]
3530struct RevokeSessionForm {
3531 id: String,
3532}
3533
3534async fn session_revoke(
3536 State(state): State<AdminState>,
3537 Extension(user): Extension<AuthenticatedUser>,
3538 Extension(session): Extension<session::SessionHandle>,
3539 Form(form): Form<RevokeSessionForm>,
3540) -> Response {
3541 match state.auth.revoke_session(user.user.id, &form.id).await {
3542 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
3543 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
3544 Err(e) => {
3545 tracing::error!(error = %e, "revoking a session failed");
3546 session.push_flash(
3547 FlashLevel::Error,
3548 t!("That session could not be signed out."),
3549 );
3550 }
3551 }
3552 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3553}
3554
3555#[derive(Deserialize)]
3557struct MenuOrderForm {
3558 #[serde(default)]
3559 order: String,
3560}
3561
3562async fn menu_order_update(
3567 State(state): State<AdminState>,
3568 Extension(user): Extension<AuthenticatedUser>,
3569 Extension(session): Extension<session::SessionHandle>,
3570 headers: axum::http::HeaderMap,
3571 Form(form): Form<MenuOrderForm>,
3572) -> Response {
3573 let order: Vec<&str> = form
3574 .order
3575 .split(',')
3576 .map(str::trim)
3577 .filter(|k| !k.is_empty() && k.len() <= 200)
3578 .take(100)
3579 .collect();
3580 let result = if order.is_empty() {
3581 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
3582 .await
3583 } else {
3584 laterite_auth::store::set_user_preference(
3585 &state.db,
3586 user.user.id,
3587 nav::ORDER_PREFERENCE,
3588 &order.join(","),
3589 )
3590 .await
3591 };
3592 if let Err(e) = result {
3593 tracing::error!(error = %e, "storing the menu order failed");
3594 return render_error();
3595 }
3596 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
3597 return axum::http::StatusCode::NO_CONTENT.into_response();
3598 }
3599 session.push_flash(
3600 FlashLevel::Success,
3601 if order.is_empty() {
3602 t!("The menu is back in its default order.")
3603 } else {
3604 t!("Saved.")
3605 },
3606 );
3607 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3608}
3609
3610async fn session_revoke_others(
3611 State(state): State<AdminState>,
3612 Extension(user): Extension<AuthenticatedUser>,
3613 Extension(session): Extension<session::SessionHandle>,
3614 jar: CookieJar,
3615) -> Response {
3616 let token = jar
3617 .get(SESSION_COOKIE)
3618 .map(|c| c.value().to_string())
3619 .unwrap_or_default();
3620 match state.auth.sign_out_everywhere(user.user.id, &token).await {
3621 Ok(_) => session.push_flash(
3622 FlashLevel::Success,
3623 t!("Every other session was signed out."),
3624 ),
3625 Err(e) => {
3626 tracing::error!(error = %e, "signing out other sessions failed");
3627 session.push_flash(
3628 FlashLevel::Error,
3629 t!("Those sessions could not be signed out."),
3630 );
3631 }
3632 }
3633 (
3636 jar.remove(remember_removal(&state.admin_path)),
3637 Redirect::to(&format!("{}/preferences", state.admin_path)),
3638 )
3639 .into_response()
3640}
3641
3642#[derive(Deserialize)]
3643struct PasswordForm {
3644 current_password: String,
3645 new_password: String,
3646 confirm_password: String,
3647}
3648
3649async fn password_update(
3654 State(state): State<AdminState>,
3655 Extension(user): Extension<AuthenticatedUser>,
3656 Extension(session): Extension<session::SessionHandle>,
3657 Extension(ctx): Extension<RequestContext>,
3658 jar: CookieJar,
3659 Form(form): Form<PasswordForm>,
3660) -> Response {
3661 let back = format!("{}/preferences#password", state.admin_path);
3662 let refuse = |message: Text| {
3663 session.push_flash(FlashLevel::Error, message);
3664 Redirect::to(&back).into_response()
3665 };
3666 if form.new_password != form.confirm_password {
3667 return refuse(t!("The new passwords do not match."));
3668 }
3669 let min_length = state.auth.password_policy().min_length;
3670 if form.new_password.chars().count() < min_length {
3671 return refuse(t!(
3672 "A password needs at least {n} characters.",
3673 n = min_length as i64
3674 ));
3675 }
3676 let remembered_here = match jar.get(REMEMBER_COOKIE) {
3679 Some(cookie) => {
3680 state
3681 .auth
3682 .remember_holder(cookie.value())
3683 .await
3684 .ok()
3685 .flatten()
3686 == Some(user.user.id)
3687 }
3688 None => false,
3689 };
3690 let token = jar
3691 .get(SESSION_COOKIE)
3692 .map(|c| c.value().to_string())
3693 .unwrap_or_default();
3694 let changed = state
3695 .auth
3696 .change_own_password(
3697 user.user.id,
3698 &form.current_password,
3699 &form.new_password,
3700 &token,
3701 &ctx,
3702 )
3703 .await;
3704 match changed {
3705 Ok(()) => {}
3706 Err(laterite_auth::AuthError::InvalidCredentials) => {
3707 return refuse(t!("Your current password is not correct."));
3708 }
3709 Err(laterite_auth::AuthError::TooManyAttempts) => {
3710 return refuse(t!("Too many attempts. Try again later."));
3711 }
3712 Err(e) => {
3713 tracing::error!(error = %e, "changing a password failed");
3714 return refuse(t!("The password could not be changed."));
3715 }
3716 }
3717 session.push_flash_sticky(
3718 FlashLevel::Success,
3719 t!("Password changed. Every other device was signed out."),
3720 );
3721 let jar = if remembered_here {
3722 match state.auth.issue_remember(user.user.id).await {
3723 Ok(credential) => jar.add(remember_cookie(
3724 credential,
3725 &state.admin_path,
3726 state.secure_cookie,
3727 )),
3728 Err(e) => {
3729 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
3730 jar.remove(remember_removal(&state.admin_path))
3731 }
3732 }
3733 } else if jar.get(REMEMBER_COOKIE).is_some() {
3734 jar.remove(remember_removal(&state.admin_path))
3737 } else {
3738 jar
3739 };
3740 (
3741 jar,
3742 Redirect::to(&format!("{}/preferences", state.admin_path)),
3743 )
3744 .into_response()
3745}
3746
3747#[derive(Deserialize)]
3748struct PreferencesForm {
3749 timezone: String,
3750 #[serde(default)]
3752 locale: String,
3753}
3754
3755async fn preferences_update(
3756 State(state): State<AdminState>,
3757 Extension(user): Extension<AuthenticatedUser>,
3758 Extension(session): Extension<session::SessionHandle>,
3759 Form(form): Form<PreferencesForm>,
3760) -> Response {
3761 let back = format!("{}/preferences", state.admin_path);
3762 let offered = offered_locales(&state.catalogs);
3763 let tz = form.timezone.trim();
3765 let tz_stored = if tz.is_empty() {
3766 None
3767 } else if tz.parse::<Tz>().is_ok() {
3768 Some(tz)
3769 } else {
3770 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
3771 return Redirect::to(&back).into_response();
3772 };
3773 let loc = form.locale.trim();
3774 let loc_stored = if loc.is_empty() {
3775 None
3776 } else if offered.iter().any(|l| l == loc) {
3777 Some(loc)
3778 } else {
3779 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
3780 return Redirect::to(&back).into_response();
3781 };
3782 if state
3783 .auth
3784 .set_user_timezone(user.user.id, tz_stored)
3785 .await
3786 .is_err()
3787 {
3788 return render_error();
3789 }
3790 match state.auth.set_user_locale(user.user.id, loc_stored).await {
3791 Ok(()) => {
3792 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
3793 Redirect::to(&back).into_response()
3794 }
3795 Err(_) => render_error(),
3796 }
3797}
3798
3799#[allow(clippy::too_many_arguments)]
3803fn preferences_view(
3804 shell: &Shell,
3805 user: &AuthenticatedUser,
3806 default_tz: Tz,
3807 default_locale: &str,
3808 offered: &[String],
3809 account: AccountSecurity,
3810 min_password: usize,
3811 items: Vec<PreferenceLink>,
3812) -> PreferencesTemplate {
3813 let current = user.user.timezone.as_deref();
3814 let zones = TZ_VARIANTS
3815 .iter()
3816 .filter(|tz| zone_offered(tz.name()))
3817 .map(|tz| TzOption {
3818 name: tz.name().to_string(),
3819 selected: current == Some(tz.name()),
3820 })
3821 .collect();
3822 let current_locale = user.user.locale.as_deref();
3823 let locales = offered
3824 .iter()
3825 .map(|code| LocaleOption {
3826 name: locale_name(code),
3827 selected: current_locale == Some(code.as_str()),
3828 code: code.clone(),
3829 })
3830 .collect();
3831 PreferencesTemplate {
3832 shell: shell.clone(),
3833 zones,
3834 effective_tz: shell.tz.name().to_string(),
3835 default_tz: default_tz.name().to_string(),
3836 inherits: current.is_none(),
3837 locales,
3838 default_locale: locale_name(default_locale),
3839 inherits_locale: current_locale.is_none(),
3840 sessions: account.sessions,
3841 password_changed: account.password_changed,
3842 must_change: account.must_change,
3843 min_password: min_password as i64,
3844 items,
3845 }
3846}
3847
3848struct AccountSecurity {
3851 sessions: Vec<SessionRow>,
3852 password_changed: Option<String>,
3853 must_change: bool,
3855}
3856
3857async fn account_security(
3858 state: &AdminState,
3859 user: &AuthenticatedUser,
3860 shell: &Shell,
3861 jar: &CookieJar,
3862) -> AccountSecurity {
3863 let locale = list::date_locale(shell.locale());
3864 let password_changed = match state.auth.password_changed_at(user.user.id).await {
3865 Ok(at) => {
3866 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
3867 }
3868 Err(e) => {
3869 tracing::error!(error = %e, "reading the password change time failed");
3870 None
3871 }
3872 };
3873 AccountSecurity {
3874 sessions: session_rows(state, user, shell, jar).await,
3875 password_changed,
3876 must_change: state
3877 .auth
3878 .must_change_password(user.user.id)
3879 .await
3880 .unwrap_or(false),
3881 }
3882}
3883
3884async fn session_rows(
3887 state: &AdminState,
3888 user: &AuthenticatedUser,
3889 shell: &Shell,
3890 jar: &CookieJar,
3891) -> Vec<SessionRow> {
3892 let token = jar
3893 .get(SESSION_COOKIE)
3894 .map(|c| c.value().to_string())
3895 .unwrap_or_default();
3896 let locale = list::date_locale(shell.locale());
3897 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
3898 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
3899 };
3900 match state.auth.list_sessions(user.user.id, &token).await {
3901 Ok(sessions) => sessions
3902 .into_iter()
3903 .map(|s| SessionRow {
3904 id: s.id,
3905 started: stamp(s.created_at),
3906 last_seen: stamp(s.last_seen_at),
3907 expires: stamp(s.expires_at),
3908 device: clientip::describe(s.user_agent.as_deref())
3909 .or(s.user_agent)
3910 .unwrap_or_else(|| "-".to_string()),
3911 ip_address: s.ip_address.unwrap_or_default(),
3912 current: s.current,
3913 })
3914 .collect(),
3915 Err(e) => {
3916 tracing::error!(error = %e, "listing sessions failed");
3917 Vec::new()
3918 }
3919 }
3920}
3921
3922fn builtin_resources() -> Vec<Resource> {
3924 vec![
3925 Resource {
3926 base_path: "/users".to_string(),
3927 nav_label: "Administrators".into(),
3928 list: backend_users_list_config(),
3929 form: None,
3930 permission: Some("backend.manage_users".to_string()),
3931 import: None,
3932 palette: None,
3933 relations: Vec::new(),
3934 menu: nav::Placement::default(),
3935 },
3936 Resource {
3937 base_path: "/roles".to_string(),
3938 nav_label: "Roles".into(),
3939 list: roles_list_config(),
3940 form: None,
3943 permission: Some("backend.manage_roles".to_string()),
3944 import: None,
3945 palette: None,
3946 relations: Vec::new(),
3947 menu: nav::Placement::default(),
3948 },
3949 resource!("descriptors/audit-log.yaml"),
3950 resource!("descriptors/access-log.yaml"),
3951 resource!("descriptors/request-log.yaml"),
3952 ]
3953}
3954
3955fn builtin_settings() -> Vec<settings::SettingsItem> {
3959 vec![
3960 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
3961 .description("Manage backend administrator accounts.")
3962 .category("Users")
3963 .order(10)
3964 .icon("users")
3965 .permission("backend.manage_users")
3966 .link("/users"),
3967 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
3968 .description("Manage roles and their permissions.")
3969 .category("Users")
3970 .order(20)
3971 .icon("shield")
3972 .permission("backend.manage_roles")
3973 .link("/roles"),
3974 settings::brand::settings_item(),
3975 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
3976 .description("Enable or disable installed plugins.")
3977 .category("System")
3978 .order(10)
3979 .icon("plug")
3980 .permission(plugins::MANAGE_PERMISSION)
3981 .link("/plugins"),
3982 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
3983 .description("Review the record of administrative changes.")
3984 .category("System")
3985 .order(20)
3986 .icon("history")
3987 .permission("backend.view_audit_log")
3988 .link("/audit-log"),
3989 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
3990 .description("Review sign-ins, failures and lockouts.")
3991 .category("System")
3992 .order(30)
3993 .icon("log-in")
3994 .permission("backend.view_access_log")
3995 .link("/access-log"),
3996 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
3997 .description("Review the URLs the panel could not serve.")
3998 .category("System")
3999 .order(31)
4000 .icon("unlink")
4001 .permission("backend.view_request_log")
4002 .link("/request-log"),
4003 ]
4004}
4005
4006fn backend_users_list_config() -> list::ListConfig {
4007 list::ListConfig {
4008 entity: "backend_users".to_string(),
4009 title: "Administrators".into(),
4010 columns: vec![
4011 list::ListColumn::new("username", "Username"),
4012 list::ListColumn::new("email", "Email"),
4013 list::ListColumn::new("first_name", "First name"),
4014 list::ListColumn::new("last_name", "Last name"),
4015 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
4016 list::ListColumn::new("is_active", "Active").yes_no(),
4017 list::ListColumn::new("created_at", "Created").datetime(),
4018 ],
4019 order_by: "created_at".to_string(),
4020 creatable: true,
4023 edit_base: Some("/users".to_string()),
4024 filters: vec![
4025 list::ListFilter::boolean("is_active", "Active"),
4026 list::ListFilter::boolean("is_superuser", "Superuser"),
4027 ],
4028 ..Default::default()
4032 }
4033}
4034
4035fn roles_list_config() -> list::ListConfig {
4036 list::ListConfig {
4037 entity: "backend_roles".to_string(),
4038 title: "Roles".into(),
4039 columns: vec![
4040 list::ListColumn::new("code", "Code"),
4041 list::ListColumn::new("name", "Name"),
4042 list::ListColumn::new("created_at", "Created").datetime(),
4043 ],
4044 order_by: "created_at".to_string(),
4045 edit_base: Some("/roles".to_string()),
4046 creatable: true,
4047 deletable: true,
4048 ..Default::default()
4049 }
4050}
4051
4052#[derive(Template)]
4053#[template(path = "login.html")]
4054struct LoginTemplate {
4055 base: String,
4057 asset_urls: Arc<AssetUrls>,
4059 brand: String,
4060 error: Option<String>,
4061 notice: Option<String>,
4063 i18n: Translator,
4066}
4067
4068fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
4071 match urls.get(key) {
4072 Some(path) => format!("{base}/assets/{path}"),
4073 None => {
4074 debug_assert!(false, "asset key `{key}` is not registered");
4075 format!("{base}/assets/{key}")
4076 }
4077 }
4078}
4079
4080impl LoginTemplate {
4081 fn asset(&self, key: &str) -> String {
4082 preauth_asset(&self.asset_urls, &self.base, key)
4083 }
4084 fn t(&self, source: &str) -> String {
4085 self.i18n.t(&Text::dynamic(source))
4086 }
4087 fn locale(&self) -> &str {
4088 self.i18n.locale()
4089 }
4090}
4091
4092#[derive(Template)]
4093#[template(path = "dashboard.html")]
4094struct DashboardTemplate {
4095 shell: Shell,
4096 username: String,
4097}
4098
4099#[derive(Template)]
4100#[template(path = "setup.html")]
4101struct SetupTemplate {
4102 base: String,
4104 asset_urls: Arc<AssetUrls>,
4106 brand: String,
4107 zones: Vec<TzOption>,
4108 error: Option<String>,
4109 i18n: Translator,
4111 min_password: i64,
4113}
4114
4115impl SetupTemplate {
4116 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
4119 let mut text = Text::dynamic(source);
4120 for (name, value) in args {
4121 text = text.arg(*name, *value);
4122 }
4123 self.i18n.t(&text)
4124 }
4125
4126 fn asset(&self, key: &str) -> String {
4127 preauth_asset(&self.asset_urls, &self.base, key)
4128 }
4129 fn t(&self, source: &str) -> String {
4130 self.i18n.t(&Text::dynamic(source))
4131 }
4132 fn locale(&self) -> &str {
4133 self.i18n.locale()
4134 }
4135}
4136
4137#[derive(Template)]
4138#[template(path = "preferences.html")]
4139struct PreferencesTemplate {
4140 shell: Shell,
4141 zones: Vec<TzOption>,
4142 effective_tz: String,
4144 default_tz: String,
4146 inherits: bool,
4148 locales: Vec<LocaleOption>,
4149 default_locale: String,
4151 inherits_locale: bool,
4153 sessions: Vec<SessionRow>,
4155 password_changed: Option<String>,
4157 must_change: bool,
4159 min_password: i64,
4161 items: Vec<PreferenceLink>,
4163}
4164
4165struct SessionRow {
4167 id: String,
4168 started: String,
4169 last_seen: String,
4170 expires: String,
4171 device: String,
4174 ip_address: String,
4176 current: bool,
4177}
4178
4179struct TzOption {
4180 name: String,
4181 selected: bool,
4182}
4183
4184struct LocaleOption {
4185 code: String,
4187 name: String,
4189 selected: bool,
4190}
4191
4192#[derive(Clone)]
4194struct CrumbView {
4195 label: String,
4196 href: String,
4197}
4198
4199#[derive(Clone)]
4200struct NavView {
4201 key: String,
4203 label: String,
4204 path: String,
4205 active: bool,
4206 icon: String,
4209}
4210
4211pub(crate) fn render<T: Template>(template: T) -> Response {
4213 match template.render() {
4214 Ok(html) => Html(html).into_response(),
4215 Err(e) => AdminError::from(e).into_response(),
4217 }
4218}
4219
4220pub(crate) fn render_error() -> Response {
4221 error::masked_500()
4222}
4223
4224pub(crate) fn not_found() -> Response {
4225 AdminError::NotFound.into_response()
4226}
4227
4228pub(crate) fn forbidden() -> Response {
4229 AdminError::Forbidden.into_response()
4230}
4231
4232#[cfg(test)]
4233mod tests {
4234 use super::*;
4235
4236 fn write_form() -> form::FormConfig {
4237 form::FormConfig {
4238 enter: Default::default(),
4239 confirm_leave: true,
4240 focus: Default::default(),
4241 entity: "widgets".to_string(),
4242 title: "Widgets".into(),
4243 base_path: "/widgets".to_string(),
4244 id_field: "id".to_string(),
4245 fields: vec![],
4246 persist: None,
4247 timestamps: false,
4248 done_path: None,
4249 }
4250 }
4251
4252 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
4253 Resource {
4254 base_path: "/widgets".to_string(),
4255 nav_label: "Widgets".into(),
4256 list: backend_users_list_config(),
4257 form,
4258 permission: permission.map(str::to_string),
4259 import: None,
4260 palette: None,
4261 relations: Vec::new(),
4262 menu: nav::Placement::default(),
4263 }
4264 }
4265
4266 #[test]
4267 #[should_panic(expected = "must declare a permission")]
4268 fn write_resource_without_permission_aborts_boot() {
4269 let resource = resource_with(Some(write_form()), None);
4272 let _ = mount_resource(
4273 &resource,
4274 &field::builtin_registry(),
4275 &list::builtin_column_registry(),
4276 &std::collections::HashSet::new(),
4277 &persist::PersisterRegistry::new(),
4278 &[],
4279 );
4280 }
4281
4282 #[test]
4284 fn the_access_log_is_read_from_its_descriptor_file() {
4285 let access = builtin_resources()
4286 .into_iter()
4287 .find(|r| r.base_path == "/access-log")
4288 .expect("the access log is registered");
4289 assert_eq!(access.list.entity, "backend_access_log");
4290 assert_eq!(
4291 access.permission.as_deref(),
4292 Some("backend.view_access_log")
4293 );
4294 assert!(access.form.is_none(), "append-only");
4295 assert_eq!(
4296 access
4297 .list
4298 .columns
4299 .iter()
4300 .map(|c| c.field.as_str())
4301 .collect::<Vec<_>>(),
4302 [
4303 "created_at",
4304 "username_attempted",
4305 "event",
4306 "ip_address",
4307 "user_agent"
4308 ]
4309 );
4310 }
4311
4312 #[test]
4315 fn the_audit_log_is_read_from_its_descriptor_file() {
4316 let audit = builtin_resources()
4317 .into_iter()
4318 .find(|r| r.base_path == "/audit-log")
4319 .expect("the audit log is registered");
4320 assert_eq!(audit.list.entity, "backend_audit_log");
4321 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
4322 assert_eq!(
4323 audit
4324 .list
4325 .columns
4326 .iter()
4327 .map(|c| c.field.as_str())
4328 .collect::<Vec<_>>(),
4329 [
4330 "created_at",
4331 "actor_username",
4332 "action",
4333 "target_type",
4334 "target_id",
4335 "target_label"
4336 ],
4337 "in the order the file writes them"
4338 );
4339 assert_eq!(audit.list.columns[0].column_type, "datetime");
4340 assert_eq!(audit.list.per_page, 50);
4341 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
4342 assert!(audit.list.exportable, "evidence may leave the panel");
4343 assert!(audit.form.is_none(), "append-only: no form");
4344 assert!(!audit.list.deletable, "and nothing removes from it");
4345 }
4346
4347 #[test]
4348 #[should_panic(expected = "uses unregistered type `nope`")]
4349 fn unregistered_column_type_aborts_boot() {
4350 let mut resource = resource_with(None, None);
4351 let mut score = list::ListColumn::new("score", "Score");
4352 score.column_type = "nope".into();
4353 resource.list.columns.push(score);
4354 let _ = mount_resource(
4355 &resource,
4356 &field::builtin_registry(),
4357 &list::builtin_column_registry(),
4358 &std::collections::HashSet::new(),
4359 &persist::PersisterRegistry::new(),
4360 &[],
4361 );
4362 }
4363
4364 #[test]
4365 fn read_only_resource_may_omit_permission() {
4366 let resource = resource_with(None, None);
4368 let _ = mount_resource(
4369 &resource,
4370 &field::builtin_registry(),
4371 &list::builtin_column_registry(),
4372 &std::collections::HashSet::new(),
4373 &persist::PersisterRegistry::new(),
4374 &[],
4375 );
4376 }
4377
4378 #[test]
4379 fn shell_localizes_through_the_translator_and_falls_back() {
4380 let shell = Shell::test();
4382 assert_eq!(shell.t("Save"), "Save");
4383 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
4384 }
4385
4386 #[test]
4387 fn shell_tf_interpolates_integer_arguments() {
4388 let shell = Shell::test();
4389 assert_eq!(
4390 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
4391 "Page 2 of 5"
4392 );
4393 }
4394
4395 #[test]
4396 fn display_tz_prefers_a_valid_operator_preference() {
4397 assert_eq!(
4398 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
4399 Tz::Asia__Kolkata
4400 );
4401 }
4402
4403 #[test]
4404 fn display_tz_falls_back_when_unset_or_invalid() {
4405 let default = Tz::Europe__London;
4406 assert_eq!(resolve_display_tz(None, default), default);
4408 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
4410 assert_eq!(resolve_display_tz(Some(""), default), default);
4411 }
4412
4413 #[test]
4414 fn accept_language_orders_by_weight_and_drops_wildcard() {
4415 assert_eq!(
4416 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
4417 vec!["en-US", "en", "kn"]
4418 );
4419 assert_eq!(
4421 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
4422 vec!["kn", "en"]
4423 );
4424 }
4425
4426 #[test]
4427 fn locale_chain_prefers_operator_then_header_then_default() {
4428 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
4430 assert_eq!(
4433 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
4434 vec!["kn", "ta", "hi", "en"]
4435 );
4436 assert_eq!(
4438 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
4439 vec!["kn", "en", "ta"]
4440 );
4441 assert_eq!(
4443 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
4444 vec!["kn", "en"]
4445 );
4446 }
4447
4448 #[test]
4449 fn locale_chain_skips_unserveable_and_defaults_to_en() {
4450 let loaded = ["hi".to_string()];
4452 assert_eq!(
4455 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
4456 vec!["en"]
4457 );
4458 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
4460 assert_eq!(
4462 resolve_locale_chain(Some("hi"), None, "en", &loaded),
4463 vec!["hi", "en"]
4464 );
4465 assert_eq!(
4467 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
4468 vec![laterite_core::PSEUDO_LOCALE, "en"]
4469 );
4470 }
4471
4472 #[test]
4473 fn page_assets_dedupe_preserve_order_and_classify() {
4474 let mut reg = AssetRegistry::new();
4475 reg.insert(
4476 "a.js",
4477 AdminAsset {
4478 mime: "text/javascript; charset=utf-8",
4479 bytes: std::borrow::Cow::Borrowed(b"//"),
4480 },
4481 );
4482 reg.insert(
4483 "b.css",
4484 AdminAsset {
4485 mime: "text/css; charset=utf-8",
4486 bytes: std::borrow::Cow::Borrowed(b"body{}"),
4487 },
4488 );
4489 let urls = asset_urls(®);
4492 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
4493 assert_eq!(assets.len(), 2);
4494 assert_eq!(
4495 assets[0].url,
4496 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
4497 );
4498 assert!(!assets[0].css);
4499 assert_eq!(
4500 assets[1].url,
4501 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
4502 );
4503 assert!(assets[1].css);
4504 assert_ne!(assets[0].url, assets[1].url);
4507 }
4508
4509 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
4513 laterite_core::testing::connect_test(&[]).await
4514 }
4515
4516 #[tokio::test]
4517 async fn builtin_migrations_create_the_admin_tables() {
4518 let (db, _guard) = empty_db().await;
4519 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
4520 .await
4521 .unwrap();
4522 for table in ["backend_users", "settings"] {
4526 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
4527 .fetch_optional(&db.pool)
4528 .await;
4529 assert!(
4530 probe.is_ok(),
4531 "{table} should exist after builtin_migrations"
4532 );
4533 }
4534 }
4535
4536 #[tokio::test]
4537 async fn brand_setting_overrides_config_and_blank_falls_back() {
4538 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
4539 let state = AdminState {
4540 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
4541 db: db.clone(),
4542 nav: Arc::new(nav::NavTree::default()),
4543 settings: Arc::new(Vec::new()),
4544 permissions: Arc::new(builtin_permissions()),
4545 trusted_proxies: Arc::new(Vec::new()),
4546 icons: Arc::new(laterite_core::icons::IconSet::new()),
4547 sprite_url: Arc::from(""),
4548 admin_path: Arc::from("/admin"),
4549 secure_cookie: false,
4550 origin: Arc::from(""),
4551 timezone: Tz::UTC,
4552 default_locale: Arc::from("en"),
4553 catalogs: Arc::new(CatalogStore::default()),
4554 app_name: "Configured Name".to_string(),
4555 brand_cache: Arc::new(RwLock::new(None)),
4556 field_types: Arc::new(field::builtin_registry()),
4557 column_types: Arc::new(list::builtin_column_registry()),
4558 plugin_defined: Arc::new(laterite_core::Registry::new()),
4559 assets: Arc::new(builtin_assets()),
4560 asset_urls: Arc::new(asset_urls(&builtin_assets())),
4561 pickers: Arc::new(picker::PickerRegistry::new()),
4562 palette: Arc::new(Vec::new()),
4563 overrides: Arc::new(field::NoOverrides),
4564 };
4565
4566 assert_eq!(state.brand().await, "Configured Name");
4568
4569 settings::store::save(
4572 &db,
4573 &settings::BrandSetting {
4574 app_name: "Acme Corp".to_string(),
4575 },
4576 )
4577 .await
4578 .unwrap();
4579 state.invalidate_brand();
4580 assert_eq!(state.brand().await, "Acme Corp");
4581
4582 settings::store::save(
4584 &db,
4585 &settings::BrandSetting {
4586 app_name: " ".to_string(),
4587 },
4588 )
4589 .await
4590 .unwrap();
4591 state.invalidate_brand();
4592 assert_eq!(state.brand().await, "Configured Name");
4593 }
4594
4595 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
4596 settings::SettingsItem {
4597 code: code.to_string(),
4598 label: code.into(),
4599 description: String::new().into(),
4600 category: "General".into(),
4601 hint: None,
4602 confirm_leave: true,
4603 order: 1,
4604 icon: None,
4605 permission: permission.map(str::to_string),
4606 link: None,
4607 fields: Vec::new(),
4608 route: None,
4609 per_operator: false,
4610 }
4611 }
4612
4613 #[test]
4614 fn settings_visibility_respects_permissions() {
4615 let items = vec![
4616 settings_item("public", None),
4617 settings_item("gated", Some("backend.manage_users")),
4618 ];
4619
4620 let none = PermissionSet::new(false, Vec::<String>::new());
4622 let codes: Vec<String> = visible_settings(&items, &none)
4623 .into_iter()
4624 .map(|i| i.code)
4625 .collect();
4626 assert_eq!(codes, ["public"]);
4627
4628 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
4630 assert_eq!(visible_settings(&items, &granted).len(), 2);
4631
4632 let superuser = PermissionSet::new(true, Vec::<String>::new());
4634 assert_eq!(visible_settings(&items, &superuser).len(), 2);
4635 }
4636
4637 #[test]
4638 fn context_sidebar_follows_settings_links() {
4639 let items = builtin_settings();
4640 let superuser = PermissionSet::new(true, Vec::<String>::new());
4641 let tr = Translator::new("en");
4642 let tree = nav::NavTree::default();
4643 let sidebar = |path: &str| {
4644 resolve_nav_context(
4645 &tree,
4646 &items,
4647 "/admin",
4648 &superuser,
4649 None,
4650 path,
4651 &tr,
4652 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
4653 )
4654 .1
4655 };
4656 let active_path = |path: &str| -> Option<String> {
4657 sidebar(path)
4658 .into_iter()
4659 .flat_map(|g| g.items)
4660 .find(|i| i.active)
4661 .map(|i| i.path)
4662 };
4663
4664 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
4666 assert_eq!(
4667 active_path("/admin/roles/42/edit").as_deref(),
4668 Some("/admin/roles")
4669 );
4670 assert!(!sidebar("/admin/settings").is_empty());
4672 assert_eq!(active_path("/admin/settings"), None);
4673 assert_eq!(
4678 active_path("/admin/settings/branding").as_deref(),
4679 Some("/admin/settings/branding")
4680 );
4681 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
4684 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
4685 assert!(sidebar("/admin").is_empty());
4687 }
4688}
4689
4690#[cfg(test)]
4691mod session_cookie_tests {
4692 use super::*;
4693
4694 #[test]
4698 fn the_session_cookie_never_outlives_the_browser() {
4699 let cookie = session_cookie("tok".into(), "/admin", false);
4700 assert_eq!(cookie.max_age(), None);
4701 assert!(cookie.http_only().unwrap_or(false));
4702 assert_eq!(cookie.path(), Some("/admin"));
4703 }
4704
4705 #[test]
4708 fn the_remember_cookie_carries_its_own_lifetime() {
4709 let credential = laterite_auth::RememberCredential {
4710 cookie: "sel:ver".into(),
4711 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
4712 };
4713 let cookie = remember_cookie(credential, "/admin", true);
4714 let age = cookie.max_age().expect("a remember cookie must persist");
4715 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
4717 assert!(cookie.http_only().unwrap_or(false));
4718 assert!(cookie.secure().unwrap_or(false));
4719 }
4720
4721 #[test]
4723 fn the_removal_matches_the_cookie_it_clears() {
4724 let removal = remember_removal("/admin");
4725 assert_eq!(removal.name(), REMEMBER_COOKIE);
4726 assert_eq!(removal.path(), Some("/admin"));
4727 }
4728}