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 &base,
1687 &admin_path,
1688 &state.origin,
1689 state.plugin_defined.clone(),
1690 state.auth.events().clone(),
1691 forms.clone(),
1692 );
1693 let info = routes::ResourceInfo::new(
1694 key,
1695 resource.base_path.clone(),
1696 resource.nav_label.clone(),
1697 link.permission.clone(),
1698 form::FormHandle::new(state.db.clone(), prepared.clone()),
1699 );
1700 protected = protected.merge(guard_with_permission(
1701 Router::new()
1702 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
1703 &link.permission,
1704 ));
1705 }
1706 for reg in &app_screens {
1710 let base = format!("{admin_path}{}", reg.base_path);
1711 let ctx = routes::RouteCtx::new(
1712 state.db.clone(),
1713 &base,
1714 &admin_path,
1715 &state.origin,
1716 state.plugin_defined.clone(),
1717 state.auth.events().clone(),
1718 forms.clone(),
1719 );
1720 protected = protected.merge(guard_with_permission(
1721 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1722 ®.permission,
1723 ));
1724 }
1725
1726 protected = protected.merge(guard_with_permission(
1729 Router::new()
1730 .route(
1731 &format!("{admin_path}/roles/new"),
1732 get(roles::new_form).post(roles::create),
1733 )
1734 .route(
1735 &format!("{admin_path}/roles/{{id}}/edit"),
1736 get(roles::edit_form).post(roles::update),
1737 ),
1738 "backend.manage_roles",
1739 ));
1740 protected = protected.merge(guard_with_permission(
1743 Router::new()
1744 .route(
1745 &format!("{admin_path}/users/new"),
1746 get(users::new_form).post(users::create),
1747 )
1748 .route(
1749 &format!("{admin_path}/users/{{id}}/edit"),
1750 get(users::edit_form).post(users::update),
1751 )
1752 .route(
1753 &format!("{admin_path}/users/{{id}}/active"),
1754 post(users::set_active),
1755 )
1756 .route(
1757 &format!("{admin_path}/users/{{id}}/password"),
1758 post(users::reset_password),
1759 )
1760 .route(
1761 &format!("{admin_path}/users/{{id}}/unlock"),
1762 post(users::unlock),
1763 ),
1764 "backend.manage_users",
1765 ));
1766 protected = protected.merge(guard_with_permission(
1769 Router::new()
1770 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1771 .route(
1772 &format!("{admin_path}/plugins/toggle"),
1773 post(plugins::toggle),
1774 ),
1775 plugins::MANAGE_PERMISSION,
1776 ));
1777 protected = protected
1778 .route(&format!("{admin_path}/settings"), get(settings_index))
1779 .route(
1780 &format!("{admin_path}/preferences"),
1781 get(preferences_form).post(preferences_update),
1782 )
1783 .route(
1784 &format!("{admin_path}/preferences/password"),
1785 post(password_update),
1786 )
1787 .route(
1788 &format!("{admin_path}/preferences/menu"),
1789 post(menu_order_update),
1790 )
1791 .route(&format!("{admin_path}/palette"), get(palette::palette))
1792 .route(
1793 &format!("{admin_path}/preferences/sessions/revoke"),
1794 post(session_revoke),
1795 )
1796 .route(
1797 &format!("{admin_path}/preferences/sessions/revoke-others"),
1798 post(session_revoke_others),
1799 )
1800 .route(
1803 &format!("{admin_path}/pickers/{{source}}/search"),
1804 any(picker::search),
1805 )
1806 .route(
1807 &format!("{admin_path}/pickers/{{source}}/resolve"),
1808 any(picker::resolve),
1809 )
1810 .route(&format!("{admin_path}/logout"), post(logout));
1811
1812 for item in settings.iter().filter(|i| i.link.is_none()) {
1816 let path = format!("{admin_path}{}", item.route());
1817 let code = item.code.clone();
1818 let edit_code = code.clone();
1819 protected = protected.route(
1820 &path,
1821 get(
1822 move |State(state): State<AdminState>,
1823 Extension(shell): Extension<Shell>,
1824 Extension(user): Extension<AuthenticatedUser>| {
1825 let code = edit_code.clone();
1826 async move { settings_edit(state, shell, user, code).await }
1827 },
1828 )
1829 .post(
1830 move |State(state): State<AdminState>,
1831 Extension(shell): Extension<Shell>,
1832 Extension(user): Extension<AuthenticatedUser>,
1833 Extension(session): Extension<session::SessionHandle>,
1834 Form(data): Form<HashMap<String, String>>| {
1835 let code = code.clone();
1836 async move { settings_update(state, shell, user, session, code, data).await }
1837 },
1838 ),
1839 );
1840 }
1841
1842 protected
1843 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1844 .route(
1848 &format!("{admin_path}/login"),
1849 get(login_form).post(login_submit),
1850 )
1851 .route(
1852 &format!("{admin_path}/setup"),
1853 get(setup_form).post(setup_submit),
1854 )
1855 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1856 .merge(mount_public(
1860 &app_public,
1861 &state.db,
1862 &admin_path,
1863 &state.origin,
1864 &state.plugin_defined,
1865 state.auth.events(),
1866 &forms,
1867 ))
1868 .fallback(not_found_fallback)
1870 .layer(middleware::from_fn_with_state(
1873 state.clone(),
1874 enforce_origin,
1875 ))
1876 .layer(middleware::from_fn_with_state(
1879 state.clone(),
1880 capture_client,
1881 ))
1882 .layer(
1887 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1888 header::CACHE_CONTROL,
1889 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1890 ),
1891 )
1892 .layer(
1896 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1897 header::VARY,
1898 axum::http::HeaderValue::from_static("HX-Request"),
1899 ),
1900 )
1901 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1902 handle_panic,
1903 ))
1904 .with_state(state)
1905}
1906
1907async fn not_found_fallback(
1910 State(state): State<AdminState>,
1911 method: axum::http::Method,
1912 uri: axum::http::Uri,
1913) -> AdminError {
1914 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
1915 AdminError::NotFound
1916}
1917
1918async fn not_found_plain() -> AdminError {
1920 AdminError::NotFound
1921}
1922
1923fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1925 let cause = err
1926 .downcast_ref::<&str>()
1927 .map(|s| s.to_string())
1928 .or_else(|| err.downcast_ref::<String>().cloned())
1929 .unwrap_or_else(|| "unknown panic".to_string());
1930 tracing::error!(panic = cause, "admin handler panicked");
1931 error::masked_500()
1932}
1933
1934pub fn normalize_path(path: &str) -> String {
1939 let trimmed = path.trim().trim_matches('/');
1940 if trimmed.is_empty() {
1941 "/admin".to_string()
1942 } else {
1943 format!("/{trimmed}")
1944 }
1945}
1946
1947fn mount_public(
1951 regs: &[routes::PublicRouteReg],
1952 db: &Db,
1953 admin_path: &str,
1954 origin: &str,
1955 plugin_defined: &Arc<laterite_core::Registry>,
1956 events: &laterite_core::Events,
1957 forms: &form::Forms,
1958) -> Router<AdminState> {
1959 let mut router = Router::new();
1960 for reg in regs {
1961 let ctx = routes::RouteCtx::new(
1962 db.clone(),
1963 ®.path,
1964 admin_path,
1965 origin,
1966 plugin_defined.clone(),
1967 events.clone(),
1968 forms.clone(),
1969 );
1970 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
1975 }
1976 router
1977}
1978
1979pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1982 let old = resource.base_path.clone();
1983 resource.base_path = base.to_string();
1984 if let Some(edit_base) = &mut resource.list.edit_base {
1985 if edit_base == &old {
1986 *edit_base = base.to_string();
1987 }
1988 }
1989 if let Some(form) = &mut resource.form {
1990 if form.base_path == old {
1991 form.base_path = base.to_string();
1992 }
1993 }
1994}
1995
1996fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1997 resource.base_path = format!("{admin_path}{}", resource.base_path);
1998 if let Some(edit_base) = &mut resource.list.edit_base {
1999 *edit_base = format!("{admin_path}{edit_base}");
2000 }
2001 if let Some(form) = &mut resource.form {
2002 form.base_path = format!("{admin_path}{}", form.base_path);
2003 }
2004}
2005
2006pub(crate) struct AdminAsset {
2008 pub mime: &'static str,
2009 pub bytes: std::borrow::Cow<'static, [u8]>,
2013}
2014
2015pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2018
2019pub(crate) type AssetUrls = HashMap<&'static str, String>;
2023
2024pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2026 registry
2027 .iter()
2028 .map(|(&key, asset)| {
2029 (
2030 key,
2031 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2032 )
2033 })
2034 .collect()
2035}
2036
2037pub(crate) fn page_assets(
2044 keys: &[&str],
2045 base: &str,
2046 registry: &AssetRegistry,
2047 urls: &AssetUrls,
2048) -> Vec<PageAsset> {
2049 let mut seen = std::collections::HashSet::new();
2050 let mut out = Vec::new();
2051 for &key in keys {
2052 if !seen.insert(key) {
2053 continue;
2054 }
2055 match (registry.get(key), urls.get(key)) {
2056 (Some(asset), Some(path)) => out.push(PageAsset {
2057 url: format!("{base}/assets/{path}"),
2058 css: asset.mime.contains("css"),
2059 }),
2060 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2061 }
2062 }
2063 out
2064}
2065
2066pub(crate) fn builtin_assets() -> AssetRegistry {
2075 let font = |bytes: &'static [u8]| AdminAsset {
2076 mime: "font/woff2",
2077 bytes: std::borrow::Cow::Borrowed(bytes),
2078 };
2079 HashMap::from([
2080 (
2081 "laterite.css",
2082 AdminAsset {
2083 mime: "text/css; charset=utf-8",
2084 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2085 },
2086 ),
2087 (
2088 "laterite.js",
2089 AdminAsset {
2090 mime: "text/javascript; charset=utf-8",
2091 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2092 },
2093 ),
2094 (
2095 "vendor/htmx.min.js",
2096 AdminAsset {
2097 mime: "text/javascript; charset=utf-8",
2098 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2099 },
2100 ),
2101 (
2102 "fields/ref-picker.js",
2103 AdminAsset {
2104 mime: "text/javascript; charset=utf-8",
2105 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
2106 },
2107 ),
2108 (
2109 "fields/ref-picker.css",
2110 AdminAsset {
2111 mime: "text/css; charset=utf-8",
2112 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2113 "../assets/fields/ref-picker.css"
2114 )),
2115 },
2116 ),
2117 (
2118 "mark.svg",
2119 AdminAsset {
2120 mime: "image/svg+xml",
2121 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
2122 },
2123 ),
2124 (
2125 "mark.png",
2126 AdminAsset {
2127 mime: "image/png",
2128 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
2129 },
2130 ),
2131 (
2132 "fonts/space-grotesk-500.woff2",
2133 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
2134 ),
2135 (
2136 "fonts/space-grotesk-600.woff2",
2137 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
2138 ),
2139 (
2140 "fonts/space-grotesk-700.woff2",
2141 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
2142 ),
2143 (
2144 "fonts/ibm-plex-sans-400.woff2",
2145 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
2146 ),
2147 (
2148 "fonts/ibm-plex-sans-600.woff2",
2149 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
2150 ),
2151 (
2152 "fonts/ibm-plex-mono-400.woff2",
2153 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
2154 ),
2155 (
2156 "fonts/ibm-plex-mono-600.woff2",
2157 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
2158 ),
2159 ])
2160}
2161
2162async fn serve_asset(
2164 State(state): State<AdminState>,
2165 headers: HeaderMap,
2166 Path(path): Path<String>,
2167) -> Response {
2168 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
2172 let Some(asset) = state.assets.get(key.as_str()) else {
2173 return not_found();
2174 };
2175 let policy = if fingerprinted {
2176 http_cache::IMMUTABLE
2177 } else {
2178 http_cache::REVALIDATE
2179 };
2180 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
2181}
2182
2183fn mount_resource(
2188 resource: &Resource,
2189 field_types: &field::FieldRegistry,
2190 column_types: &list::ColumnRegistry,
2191 permission_codes: &std::collections::HashSet<String>,
2192 persisters: &persist::PersisterRegistry,
2193 listeners: &[laterite_core::ModelListenerReg],
2194) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2195 list::check(&resource.list, column_types, permission_codes)
2199 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2200 if resource.form.is_some() && resource.permission.is_none() {
2205 panic!(
2206 "admin resource `{}` has a create/edit form but no permission; a write \
2207 resource must declare a permission",
2208 resource.base_path
2209 );
2210 }
2211 let base = resource.base_path.clone();
2212 let list_path = base.clone();
2213 let mut list_cfg = resource.list.clone();
2214 if resource.list.columns.iter().any(|c| c.editable) {
2217 let Some(form_cfg) = resource.form.clone() else {
2218 panic!(
2219 "admin resource `{}` has an editable column but no form to edit through",
2220 resource.base_path
2221 );
2222 };
2223 for column in resource.list.columns.iter().filter(|c| c.editable) {
2224 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2225 panic!(
2226 "admin resource `{}`: column `{}` is editable but the form has no such field",
2227 resource.base_path, column.field
2228 );
2229 }
2230 }
2231 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2232 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2233 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2234 }
2235 let cell_cfg = list_cfg.clone();
2236 let mut router = Router::new().route(
2237 &base,
2238 get(
2239 move |State(state): State<AdminState>,
2240 Extension(shell): Extension<Shell>,
2241 Query(params): Query<list::ListParams>,
2242 Query(raw): Query<std::collections::HashMap<String, String>>,
2243 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2244 Extension(user): Extension<AuthenticatedUser>,
2245 Extension(session): Extension<session::SessionHandle>,
2246 headers: axum::http::HeaderMap| {
2247 let cfg = list_cfg.clone();
2248 let path = list_path.clone();
2249 async move {
2250 list::handle_remembering(
2251 &state,
2252 &cfg,
2253 &path,
2254 params,
2255 &raw,
2256 &user,
2257 shell,
2258 &headers,
2259 query_string.as_deref(),
2260 Some(&session),
2261 )
2262 .await
2263 }
2264 },
2265 ),
2266 );
2267
2268 if cell_cfg.editor.is_some() {
2269 let (cfg, list_path) = (cell_cfg, base.clone());
2270 router = router.route(
2271 &format!("{base}/{{id}}/cell"),
2272 post(
2273 move |State(state): State<AdminState>,
2274 Extension(shell): Extension<Shell>,
2275 Extension(user): Extension<AuthenticatedUser>,
2276 Extension(session): Extension<session::SessionHandle>,
2277 Path(id): Path<String>,
2278 headers: axum::http::HeaderMap,
2279 Form(data): Form<HashMap<String, String>>| {
2280 let (cfg, path) = (cfg.clone(), list_path.clone());
2281 async move {
2282 list::cell_handler(
2283 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2284 )
2285 .await
2286 }
2287 },
2288 ),
2289 );
2290 }
2291
2292 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2295 if resource.list.exportable {
2296 router = router.route(
2297 &format!("{base}/export"),
2298 get(
2299 move |state: State<AdminState>,
2300 Query(raw): Query<std::collections::HashMap<String, String>>,
2301 Extension(user): Extension<AuthenticatedUser>,
2302 Extension(shell): Extension<Shell>,
2303 Extension(session): Extension<session::SessionHandle>| {
2304 let cfg = export_cfg.clone();
2305 let path = export_path.clone();
2306 async move {
2307 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2308 }
2309 },
2310 ),
2311 );
2312 }
2313
2314 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2315 router = router.route(
2316 &format!("{base}/columns"),
2317 post(
2318 move |State(state): State<AdminState>,
2319 Extension(user): Extension<AuthenticatedUser>,
2320 Extension(session): Extension<session::SessionHandle>,
2321 headers: axum::http::HeaderMap,
2322 Form(pairs): Form<Vec<(String, String)>>| {
2323 let cfg = columns_cfg.clone();
2324 let path = columns_path.clone();
2325 async move {
2326 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2327 }
2328 },
2329 ),
2330 );
2331
2332 if resource.list.deletable {
2333 let persister: Arc<dyn persist::Persister> =
2337 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
2338 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
2339 panic!(
2340 "admin resource `{}`: unregistered persister `{key}`",
2341 resource.base_path
2342 )
2343 }),
2344 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
2345 };
2346 let ctx = Arc::new(bulk::BulkContext {
2347 base_path: resource.base_path.clone(),
2348 entity: resource.list.entity.clone(),
2349 persister,
2350 listeners: listeners
2351 .iter()
2352 .filter(|reg| reg.matches(&resource.list.entity))
2353 .map(|reg| reg.listener.clone())
2354 .collect(),
2355 });
2356 router =
2357 router.route(
2358 &format!("{base}/delete"),
2359 post(
2360 move |state: State<AdminState>,
2361 user: Extension<AuthenticatedUser>,
2362 session: Extension<session::SessionHandle>,
2363 headers: axum::http::HeaderMap,
2364 form: Form<Vec<(String, String)>>| {
2365 let ctx = ctx.clone();
2366 async move {
2367 bulk::delete_handler(state, user, session, ctx, headers, form).await
2368 }
2369 },
2370 ),
2371 );
2372 }
2373
2374 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
2377 let tree = resource.list.tree.clone().unwrap_or_default();
2378 let mut move_cfg = form::FormConfig::new(
2379 resource.list.entity.clone(),
2380 laterite_core::t!("Move to"),
2381 base.clone(),
2382 resource.list.id_field.clone(),
2383 vec![form::FormField::reference(
2384 &tree.parent_key,
2385 laterite_core::t!("New parent"),
2386 &source,
2387 )
2388 .help(laterite_core::t!("Leave empty for the top level."))],
2389 );
2390 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
2391 move_cfg = move_cfg.persist(persist);
2392 }
2393 move_cfg.done_path = Some(base.clone());
2394 let move_form = Arc::new(
2395 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
2396 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
2397 );
2398 let (cfg, list_path) = (resource.list.clone(), base.clone());
2399 let (form_get, form_post) = (move_form.clone(), move_form);
2400 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
2401 router = router.route(
2402 &format!("{base}/{{id}}/move"),
2403 get(
2404 move |State(state): State<AdminState>,
2405 Extension(shell): Extension<Shell>,
2406 Path(id): Path<String>| {
2407 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
2408 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
2409 },
2410 )
2411 .post(
2412 move |State(state): State<AdminState>,
2413 Extension(shell): Extension<Shell>,
2414 Extension(user): Extension<AuthenticatedUser>,
2415 Extension(session): Extension<session::SessionHandle>,
2416 Path(id): Path<String>,
2417 headers: axum::http::HeaderMap,
2418 Form(data): Form<HashMap<String, String>>| {
2419 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
2420 async move {
2421 list::move_record(
2422 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
2423 )
2424 .await
2425 }
2426 },
2427 ),
2428 );
2429 }
2430
2431 if resource.list.reorder.is_some() {
2432 let (cfg, list_path) = (resource.list.clone(), base.clone());
2433 router = router.route(
2434 &format!("{base}/reorder"),
2435 post(
2436 move |State(state): State<AdminState>,
2437 Extension(shell): Extension<Shell>,
2438 Extension(user): Extension<AuthenticatedUser>,
2439 Extension(session): Extension<session::SessionHandle>,
2440 headers: axum::http::HeaderMap,
2441 Form(pairs): Form<Vec<(String, String)>>| {
2442 let cfg = cfg.clone();
2443 let path = list_path.clone();
2444 async move {
2445 list::reorder_handler(
2446 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
2447 &headers, &pairs,
2448 )
2449 .await
2450 }
2451 },
2452 ),
2453 );
2454 }
2455
2456 if !resource.relations.is_empty() && resource.form.is_none() {
2459 panic!(
2460 "admin resource `{}` declares relations but has no form to show them on",
2461 resource.base_path
2462 );
2463 }
2464 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
2465 for rel in &resource.relations {
2466 let prepared = relation::PreparedRelation::prepare(
2467 resource,
2468 rel.clone(),
2469 field_types,
2470 column_types,
2471 permission_codes,
2472 persisters,
2473 listeners,
2474 )
2475 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2476 relations.push(Arc::new(prepared));
2477 }
2478 let relations = Arc::new(relations);
2479
2480 let mut prepared_form = None;
2481 if let Some(form_cfg) = resource.form.clone() {
2482 let mut prepared =
2485 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2486 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2487 prepared.stay = !relations.is_empty();
2489 let prepared = Arc::new(prepared);
2490 prepared_form = Some(prepared.clone());
2491 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
2492 let relations_for_new = relations.clone();
2493 router = router.route(
2494 &format!("{base}/new"),
2495 get(
2496 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
2497 let pf = new_pf.clone();
2498 let relations = relations_for_new.clone();
2499 async move {
2500 let pending = relations
2501 .iter()
2502 .map(|r| shell.tt(&r.config.title))
2503 .collect();
2504 form::new_form(&state, &pf, shell, pending)
2505 }
2506 },
2507 )
2508 .post(
2509 move |State(state): State<AdminState>,
2510 Extension(shell): Extension<Shell>,
2511 Extension(user): Extension<AuthenticatedUser>,
2512 Extension(session): Extension<session::SessionHandle>,
2513 headers: axum::http::HeaderMap,
2514 Form(data): Form<HashMap<String, String>>| {
2515 let pf = create_pf.clone();
2516 async move {
2517 form::create(&state, &pf, data, shell, &user, &session, &headers).await
2518 }
2519 },
2520 ),
2521 );
2522
2523 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
2524 let relations_for_edit = relations.clone();
2525 router = router.route(
2526 &format!("{base}/{{id}}/edit"),
2527 get(
2528 move |State(state): State<AdminState>,
2529 Extension(shell): Extension<Shell>,
2530 Extension(user): Extension<AuthenticatedUser>,
2531 Path(id): Path<String>| {
2532 let pf = edit_pf.clone();
2533 let relations = relations_for_edit.clone();
2534 async move {
2535 let mut sections = Vec::new();
2536 for rel in relations.iter() {
2537 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
2538 sections.push(section);
2539 }
2540 }
2541 form::edit_form(&state, &pf, id, shell, sections).await
2542 }
2543 },
2544 )
2545 .post(
2546 move |State(state): State<AdminState>,
2547 Extension(shell): Extension<Shell>,
2548 Extension(user): Extension<AuthenticatedUser>,
2549 Extension(session): Extension<session::SessionHandle>,
2550 Path(id): Path<String>,
2551 headers: axum::http::HeaderMap,
2552 Form(data): Form<HashMap<String, String>>| {
2553 let pf = update_pf.clone();
2554 async move {
2555 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
2556 }
2557 },
2558 ),
2559 );
2560 }
2561
2562 for rel in relations.iter() {
2563 let name = rel.config.name.clone();
2564 let mut routes: Router<AdminState> = Router::new();
2565 let r = rel.clone();
2566 routes =
2567 routes.route(
2568 &format!("{base}/{{id}}/{name}"),
2569 get(
2570 move |state: State<AdminState>,
2571 shell: Extension<Shell>,
2572 user: Extension<AuthenticatedUser>,
2573 id: Path<String>,
2574 params: Query<list::ListParams>,
2575 raw: Query<HashMap<String, String>>,
2576 headers: axum::http::HeaderMap| {
2577 let r = r.clone();
2578 async move {
2579 relation::index(state, shell, user, id, params, raw, headers, r).await
2580 }
2581 },
2582 ),
2583 );
2584 let (r_new, r_create) = (rel.clone(), rel.clone());
2585 routes = routes.route(
2586 &format!("{base}/{{id}}/{name}/new"),
2587 get(
2588 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
2589 let r = r_new.clone();
2590 async move { relation::new_form(state, shell, id, r).await }
2591 },
2592 )
2593 .post(
2594 move |state: State<AdminState>,
2595 shell: Extension<Shell>,
2596 user: Extension<AuthenticatedUser>,
2597 session: Extension<session::SessionHandle>,
2598 id: Path<String>,
2599 headers: axum::http::HeaderMap,
2600 data: Form<HashMap<String, String>>| {
2601 let r = r_create.clone();
2602 async move {
2603 relation::create(state, shell, user, session, id, headers, data, r).await
2604 }
2605 },
2606 ),
2607 );
2608 let (r_edit, r_update) = (rel.clone(), rel.clone());
2609 routes = routes.route(
2610 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
2611 get(
2612 move |state: State<AdminState>,
2613 shell: Extension<Shell>,
2614 ids: Path<(String, String)>| {
2615 let r = r_edit.clone();
2616 async move { relation::edit_form(state, shell, ids, r).await }
2617 },
2618 )
2619 .post(
2620 move |state: State<AdminState>,
2621 shell: Extension<Shell>,
2622 user: Extension<AuthenticatedUser>,
2623 session: Extension<session::SessionHandle>,
2624 ids: Path<(String, String)>,
2625 headers: axum::http::HeaderMap,
2626 data: Form<HashMap<String, String>>| {
2627 let r = r_update.clone();
2628 async move {
2629 relation::update(state, shell, user, session, ids, headers, data, r).await
2630 }
2631 },
2632 ),
2633 );
2634 let r_delete = rel.clone();
2635 routes = routes.route(
2636 &format!("{base}/{{id}}/{name}/delete"),
2637 post(
2638 move |state: State<AdminState>,
2639 shell: Extension<Shell>,
2640 user: Extension<AuthenticatedUser>,
2641 session: Extension<session::SessionHandle>,
2642 id: Path<String>,
2643 headers: axum::http::HeaderMap,
2644 pairs: Form<Vec<(String, String)>>| {
2645 let r = r_delete.clone();
2646 async move {
2647 relation::delete(state, shell, user, session, id, headers, pairs, r).await
2648 }
2649 },
2650 ),
2651 );
2652 if rel.reorderable() {
2653 let r_reorder = rel.clone();
2654 routes = routes.route(
2655 &format!("{base}/{{id}}/{name}/reorder"),
2656 post(
2657 move |state: State<AdminState>,
2658 shell: Extension<Shell>,
2659 user: Extension<AuthenticatedUser>,
2660 session: Extension<session::SessionHandle>,
2661 id: Path<String>,
2662 headers: axum::http::HeaderMap,
2663 pairs: Form<Vec<(String, String)>>| {
2664 let r = r_reorder.clone();
2665 async move {
2666 relation::reorder(state, shell, user, session, id, headers, pairs, r)
2667 .await
2668 }
2669 },
2670 ),
2671 );
2672 }
2673 if let Some(permission) = &rel.config.permission {
2674 routes = guard_with_permission(routes, permission);
2675 }
2676 router = router.merge(routes);
2677 }
2678
2679 if let Some(permission) = &resource.permission {
2681 router = guard_with_permission(router, permission);
2682 }
2683 (router, prepared_form)
2684}
2685
2686fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
2691 let needed: Arc<str> = Arc::from(permission);
2692 router.route_layer(middleware::from_fn(
2693 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
2694 let needed = needed.clone();
2695 async move {
2696 if user.allows(&needed) {
2697 next.run(req).await
2698 } else {
2699 forbidden()
2700 }
2701 }
2702 },
2703 ))
2704}
2705
2706async fn require_auth(
2712 State(state): State<AdminState>,
2713 jar: CookieJar,
2714 request: Request,
2715 next: Next,
2716) -> Response {
2717 let login = format!("{}/login", state.admin_path);
2718 let client_ctx = request
2719 .extensions()
2720 .get::<RequestContext>()
2721 .cloned()
2722 .unwrap_or_default();
2723 let mut ended: Option<laterite_auth::SessionEnd> = None;
2728 let live = match jar.get(SESSION_COOKIE) {
2729 Some(cookie) => {
2730 let token = cookie.value().to_string();
2731 match state.auth.resolve_session(&token).await {
2732 Ok(resolved) => Some((token, resolved)),
2733 Err(_) => {
2734 ended = state.auth.session_end(&token).await.ok().flatten();
2735 None
2736 }
2737 }
2738 }
2739 None => None,
2740 };
2741 let htmx = request.headers().contains_key("hx-request");
2742 let (token, resolved, recalled) = match live {
2743 Some((token, resolved)) => (token, resolved, None),
2744 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2747 return signed_out(&state, jar, &login, ended, htmx);
2748 }
2749 None => match recall(&state, &jar, &client_ctx).await {
2750 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2751 None => {
2752 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2756 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2757 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2758 }
2759 }
2760 return signed_out(&state, jar, &login, ended, htmx);
2761 }
2762 },
2763 };
2764 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2765
2766 let mut request = request;
2771 let safe = session::is_safe_method(request.method());
2772 let mut deferred: Option<session::CsrfPending> = None;
2774 if !safe {
2775 let (parts, body) = request.into_parts();
2776 let (body, submitted) = if session::is_multipart(&parts.headers) {
2783 let token = session::header_token(&parts.headers)
2784 .or_else(|| session::query_token(parts.uri.query()));
2785 if token.is_none() {
2790 deferred = Some(session::CsrfPending::default());
2791 }
2792 (body, token)
2793 } else {
2794 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2795 .await
2796 .unwrap_or_default();
2797 let token = session::submitted_token(&parts.headers, &bytes);
2798 (Body::from(bytes), token)
2799 };
2800 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2801 {
2802 tracing::warn!(
2803 user_id = resolved.identity.user.id,
2804 "admin CSRF check failed"
2805 );
2806 return error::csrf_rejected();
2807 }
2808 request = Request::from_parts(parts, body);
2809 }
2810
2811 let user = resolved.identity;
2812 let acting_user_id = user.user.id;
2814 let path = request.uri().path().to_string();
2815 let preferences = format!("{}/preferences", state.admin_path);
2818 if !path.starts_with(&preferences)
2819 && path != format!("{}/logout", state.admin_path)
2820 && state
2821 .auth
2822 .must_change_password(user.user.id)
2823 .await
2824 .unwrap_or(false)
2825 {
2826 let to = format!("{preferences}#password");
2827 let response = if htmx {
2828 (
2829 [("HX-Redirect", to.as_str())],
2830 axum::http::StatusCode::NO_CONTENT,
2831 )
2832 .into_response()
2833 } else {
2834 Redirect::to(&to).into_response()
2835 };
2836 return with_recall_cookies(response, recalled, jar, token, &state);
2837 }
2838 let flash = if safe {
2841 handle.take_flash()
2842 } else {
2843 Vec::new()
2844 };
2845 let accept_language = request
2849 .headers()
2850 .get("accept-language")
2851 .and_then(|v| v.to_str().ok());
2852 let serveable = state.catalogs.locales();
2853 let chain = resolve_locale_chain(
2854 user.user.locale.as_deref(),
2855 accept_language,
2856 &state.default_locale,
2857 &serveable,
2858 );
2859 let i18n = Translator::with_chain(chain, state.catalogs.clone());
2860 let translator = i18n.clone();
2863 let own_order =
2865 laterite_auth::store::user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
2866 .await
2867 .unwrap_or_default();
2868 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
2869 &state.nav,
2870 &state.settings,
2871 &state.admin_path,
2872 &user.permissions,
2873 own_order.as_deref(),
2874 &path,
2875 &i18n,
2876 icons::Icons::new(&state.icons, &state.sprite_url),
2877 );
2878 let brand = state.brand().await;
2879 let shell = Shell::new(
2880 &state.admin_path,
2881 brand,
2882 &visible_nav,
2883 &user,
2884 state.timezone,
2885 sidebar,
2886 active_nav.as_deref(),
2887 handle.csrf_token(),
2888 flash,
2889 i18n,
2890 state.asset_urls.clone(),
2891 state.origin.to_string(),
2892 state.icons.clone(),
2893 state.sprite_url.clone(),
2894 );
2895 request.extensions_mut().insert(user);
2896 request.extensions_mut().insert(shell);
2897 request.extensions_mut().insert(handle.clone());
2898 if let Some(pending) = &deferred {
2900 request.extensions_mut().insert(pending.clone());
2901 }
2902 let mut response = next.run(request).await;
2903
2904 if let Some(pending) = &deferred {
2909 if !pending.was_satisfied() {
2910 tracing::error!(
2911 user_id = acting_user_id,
2912 "a multipart route returned without verifying its request token"
2913 );
2914 return error::csrf_rejected();
2915 }
2916 }
2917
2918 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2922 response = error::localized_error(meta.kind, &translator);
2923 }
2924
2925 if let Some(blob) = handle.dirty_blob() {
2928 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2929 tracing::error!(error = %e, "persisting admin session failed");
2930 }
2931 }
2932 with_recall_cookies(response, recalled, jar, token, &state)
2933}
2934
2935fn with_recall_cookies(
2939 response: Response,
2940 recalled: Option<laterite_auth::RememberCredential>,
2941 jar: CookieJar,
2942 token: String,
2943 state: &AdminState,
2944) -> Response {
2945 match recalled {
2946 Some(credential) => {
2947 let jar = jar
2948 .add(session_cookie(
2949 token,
2950 &state.admin_path,
2951 state.secure_cookie,
2952 ))
2953 .add(remember_cookie(
2954 credential,
2955 &state.admin_path,
2956 state.secure_cookie,
2957 ));
2958 (jar, response).into_response()
2959 }
2960 None => response,
2961 }
2962}
2963
2964async fn recall(
2968 state: &AdminState,
2969 jar: &CookieJar,
2970 ctx: &RequestContext,
2971) -> Option<(
2972 String,
2973 laterite_auth::ResolvedSession,
2974 laterite_auth::RememberCredential,
2975)> {
2976 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
2977 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
2978 let token = recalled.session.token.clone();
2979 let resolved = state.auth.resolve_session(&token).await.ok()?;
2980 Some((token, resolved, recalled.remember))
2981}
2982
2983const MAX_FORM_BYTES: usize = 1024 * 1024;
2986
2987async fn capture_client(
2995 State(state): State<AdminState>,
2996 mut request: Request,
2997 next: Next,
2998) -> Response {
2999 let peer = request
3000 .extensions()
3001 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3002 .map(|info| info.0);
3003 let headers = request.headers();
3004 let ctx = RequestContext {
3005 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3006 user_agent: clientip::user_agent(headers),
3007 };
3008 request.extensions_mut().insert(ctx);
3009 next.run(request).await
3010}
3011
3012async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3013 if !session::is_safe_method(request.method())
3014 && !session::origin_ok(request.headers(), &state.origin)
3015 {
3016 tracing::warn!(
3017 path = request.uri().path(),
3018 "admin CSRF origin check failed"
3019 );
3020 return error::csrf_rejected();
3021 }
3022 next.run(request).await
3023}
3024
3025#[derive(Deserialize)]
3026struct LoginQuery {
3027 ended: Option<String>,
3028}
3029
3030async fn login_form(
3031 State(state): State<AdminState>,
3032 headers: HeaderMap,
3033 Query(query): Query<LoginQuery>,
3034) -> Response {
3035 match state.auth.has_any_operator().await {
3037 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
3038 Ok(true) => {
3039 let i18n = pre_auth_translator(&state, &headers);
3040 let notice = query
3041 .ended
3042 .as_deref()
3043 .and_then(end_notice)
3044 .map(|text| i18n.t(&text));
3045 render(LoginTemplate {
3046 base: state.admin_path.to_string(),
3047 asset_urls: state.asset_urls.clone(),
3048 brand: state.brand().await,
3049 error: None,
3050 notice,
3051 i18n,
3052 })
3053 }
3054 Err(_) => render_error(),
3055 }
3056}
3057
3058#[derive(Deserialize)]
3059struct LoginForm {
3060 username: String,
3061 password: String,
3062 #[serde(default)]
3064 remember: Option<String>,
3065}
3066
3067async fn login_submit(
3068 State(state): State<AdminState>,
3069 jar: CookieJar,
3070 headers: HeaderMap,
3071 Extension(ctx): Extension<RequestContext>,
3072 Form(form): Form<LoginForm>,
3073) -> Response {
3074 match state
3075 .auth
3076 .authenticate(&form.username, &form.password, &ctx)
3077 .await
3078 {
3079 Ok(session) => {
3080 let wants_remember = form.remember.is_some();
3081 let user_id = session.user_id;
3082 let mut jar = jar.add(session_cookie(
3083 session.token,
3084 &state.admin_path,
3085 state.secure_cookie,
3086 ));
3087 if wants_remember {
3088 match state.auth.issue_remember(user_id).await {
3089 Ok(credential) => {
3090 jar = jar.add(remember_cookie(
3091 credential,
3092 &state.admin_path,
3093 state.secure_cookie,
3094 ));
3095 }
3096 Err(e) => {
3098 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
3099 }
3100 }
3101 }
3102 (jar, Redirect::to(&state.admin_path)).into_response()
3103 }
3104 Err(_) => {
3105 let i18n = pre_auth_translator(&state, &headers);
3106 render(LoginTemplate {
3107 base: state.admin_path.to_string(),
3108 asset_urls: state.asset_urls.clone(),
3109 brand: state.brand().await,
3110 error: Some(i18n.t(&t!("Invalid username or password."))),
3111 notice: None,
3112 i18n,
3113 })
3114 }
3115 }
3116}
3117
3118fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
3127 Cookie::build((SESSION_COOKIE, token))
3128 .path(admin_path.to_string())
3129 .http_only(true)
3130 .secure(secure)
3131 .same_site(SameSite::Lax)
3132 .build()
3133}
3134
3135fn remember_cookie(
3138 credential: laterite_auth::RememberCredential,
3139 admin_path: &str,
3140 secure: bool,
3141) -> Cookie<'static> {
3142 let seconds = (credential.expires_at - chrono::Utc::now())
3143 .num_seconds()
3144 .max(0);
3145 Cookie::build((REMEMBER_COOKIE, credential.cookie))
3146 .path(admin_path.to_string())
3147 .http_only(true)
3148 .secure(secure)
3149 .same_site(SameSite::Lax)
3150 .max_age(cookie::time::Duration::seconds(seconds))
3151 .build()
3152}
3153
3154fn signed_out(
3161 state: &AdminState,
3162 jar: CookieJar,
3163 login: &str,
3164 ended: Option<laterite_auth::SessionEnd>,
3165 htmx: bool,
3166) -> Response {
3167 let to = match ended.map(laterite_auth::SessionEnd::code) {
3168 Some(code) => format!("{login}?ended={code}"),
3169 None => login.to_string(),
3170 };
3171 let jar = jar
3172 .remove(
3173 Cookie::build((SESSION_COOKIE, ""))
3174 .path(state.admin_path.to_string())
3175 .build(),
3176 )
3177 .remove(remember_removal(&state.admin_path));
3178 if htmx {
3179 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
3180 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
3181 response.headers_mut().insert("hx-redirect", value);
3182 }
3183 return response;
3184 }
3185 (jar, Redirect::to(&to)).into_response()
3186}
3187
3188fn end_notice(code: &str) -> Option<Text> {
3191 Some(match code {
3192 "password_changed" => {
3193 t!("Your password was changed, so you were signed out. Sign in with the new one.")
3194 }
3195 "deactivated" => t!("This account was deactivated."),
3196 "signed_out_elsewhere" => t!("You were signed out from another device."),
3197 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3198 _ => return None,
3199 })
3200}
3201
3202fn remember_removal(admin_path: &str) -> Cookie<'static> {
3204 Cookie::build((REMEMBER_COOKIE, ""))
3205 .path(admin_path.to_string())
3206 .build()
3207}
3208
3209#[derive(Deserialize)]
3210struct SetupForm {
3211 username: String,
3212 first_name: String,
3213 last_name: String,
3214 email: String,
3215 password: String,
3216 timezone: String,
3217}
3218
3219async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3222 match state.auth.has_any_operator().await {
3223 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3224 Ok(false) => render(setup_view(
3225 &state.admin_path,
3226 state.brand().await,
3227 state.timezone,
3228 None,
3229 pre_auth_translator(&state, &headers),
3230 state.asset_urls.clone(),
3231 state.auth.password_policy().min_length,
3232 )),
3233 Err(_) => render_error(),
3234 }
3235}
3236
3237async fn setup_submit(
3238 State(state): State<AdminState>,
3239 jar: CookieJar,
3240 headers: HeaderMap,
3241 client: Option<Extension<RequestContext>>,
3242 Form(form): Form<SetupForm>,
3243) -> Response {
3244 match state.auth.has_any_operator().await {
3246 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3247 Ok(false) => {}
3248 Err(_) => return render_error(),
3249 }
3250
3251 let username = form.username.trim();
3252 let email = form.email.trim();
3253 let first_name = form.first_name.trim();
3254 let last_name = form.last_name.trim();
3255 let tz = form.timezone.trim();
3256 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3257 {
3258 return render(setup_view(
3259 &state.admin_path,
3260 state.brand().await,
3261 state.timezone,
3262 Some(t!(
3263 "Username, first name, email, and password are all required."
3264 )),
3265 pre_auth_translator(&state, &headers),
3266 state.asset_urls.clone(),
3267 state.auth.password_policy().min_length,
3268 ));
3269 }
3270 if tz.parse::<Tz>().is_err() {
3272 return render(setup_view(
3273 &state.admin_path,
3274 state.brand().await,
3275 state.timezone,
3276 Some(t!("That is not a recognised timezone.")),
3277 pre_auth_translator(&state, &headers),
3278 state.asset_urls.clone(),
3279 state.auth.password_policy().min_length,
3280 ));
3281 }
3282
3283 let min_length = state.auth.password_policy().min_length;
3284 if form.password.chars().count() < min_length {
3285 return render(setup_view(
3286 &state.admin_path,
3287 state.brand().await,
3288 state.timezone,
3289 Some(t!(
3290 "A password needs at least {n} characters.",
3291 n = min_length as i64
3292 )),
3293 pre_auth_translator(&state, &headers),
3294 state.asset_urls.clone(),
3295 state.auth.password_policy().min_length,
3296 ));
3297 }
3298 let new = NewOperator {
3299 username,
3300 email,
3301 first_name,
3302 last_name: (!last_name.is_empty()).then_some(last_name),
3303 password: &form.password,
3304 timezone: Some(tz),
3305 };
3306 let id = match state.auth.create_superuser(new).await {
3307 Ok(id) => id,
3308 Err(_) => {
3309 return render(setup_view(
3310 &state.admin_path,
3311 state.brand().await,
3312 state.timezone,
3313 Some(t!(
3314 "Could not create the account. The username or email may already be taken."
3315 )),
3316 pre_auth_translator(&state, &headers),
3317 state.asset_urls.clone(),
3318 state.auth.password_policy().min_length,
3319 ));
3320 }
3321 };
3322 let actor = laterite_core::Actor::system("first-run setup");
3325 let target = id.to_string();
3326 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
3327 "backend_user",
3328 &target,
3329 Some(username),
3330 );
3331 if let Err(e) = state.auth.record_audit(entry).await {
3332 tracing::error!(error = %e, "failed to write audit log entry");
3333 }
3334
3335 match state
3337 .auth
3338 .authenticate(
3339 username,
3340 &form.password,
3341 &client.map(|c| c.0).unwrap_or_default(),
3342 )
3343 .await
3344 {
3345 Ok(session) => {
3346 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
3347 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
3348 }
3349 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3350 }
3351}
3352
3353fn setup_view(
3356 admin_path: &str,
3357 brand: String,
3358 default_tz: Tz,
3359 error: Option<Text>,
3360 i18n: Translator,
3361 asset_urls: Arc<AssetUrls>,
3362 min_password: usize,
3363) -> SetupTemplate {
3364 let default_name = default_tz.name();
3365 let zones = TZ_VARIANTS
3366 .iter()
3367 .filter(|tz| zone_offered(tz.name()))
3368 .map(|tz| TzOption {
3369 name: tz.name().to_string(),
3370 selected: tz.name() == default_name,
3371 })
3372 .collect();
3373 SetupTemplate {
3374 base: admin_path.to_string(),
3375 asset_urls,
3376 brand,
3377 zones,
3378 error: error.map(|e| i18n.t(&e)),
3379 min_password: min_password as i64,
3380 i18n,
3381 }
3382}
3383
3384async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
3385 if let Some(cookie) = jar.get(SESSION_COOKIE) {
3386 let _ = state.auth.logout(cookie.value()).await;
3387 }
3388 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3391 let _ = state.auth.revoke_remember(cookie.value()).await;
3392 }
3393 let removal = Cookie::build((SESSION_COOKIE, ""))
3394 .path(state.admin_path.to_string())
3395 .build();
3396 (
3397 jar.remove(removal)
3398 .remove(remember_removal(&state.admin_path)),
3399 Redirect::to(&format!("{}/login", state.admin_path)),
3400 )
3401 .into_response()
3402}
3403
3404async fn dashboard(
3405 Extension(shell): Extension<Shell>,
3406 Extension(user): Extension<AuthenticatedUser>,
3407) -> Response {
3408 render(DashboardTemplate {
3409 username: user.user.username,
3410 shell,
3411 })
3412}
3413
3414fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
3417 match &item.permission {
3418 None => true,
3419 Some(p) => perms.allows(p),
3420 }
3421}
3422
3423fn visible_settings(
3427 items: &[settings::SettingsItem],
3428 perms: &PermissionSet,
3429) -> Vec<settings::SettingsItem> {
3430 items
3431 .iter()
3432 .filter(|item| operator_can_see(item, perms))
3433 .cloned()
3434 .collect()
3435}
3436
3437async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
3438 settings::index(shell)
3439}
3440
3441async fn settings_edit(
3442 state: AdminState,
3443 shell: Shell,
3444 user: AuthenticatedUser,
3445 code: String,
3446) -> Response {
3447 let items = visible_settings(&state.settings, &user.permissions);
3450 match items
3451 .iter()
3452 .find(|item| item.code == code && item.link.is_none())
3453 {
3454 Some(item) => settings::edit_form(&state, item, shell, &user).await,
3455 None => not_found(),
3456 }
3457}
3458
3459async fn settings_update(
3460 state: AdminState,
3461 shell: Shell,
3462 user: AuthenticatedUser,
3463 session: session::SessionHandle,
3464 code: String,
3465 data: HashMap<String, String>,
3466) -> Response {
3467 let items = visible_settings(&state.settings, &user.permissions);
3468 match items
3469 .iter()
3470 .find(|item| item.code == code && item.link.is_none())
3471 {
3472 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
3473 None => not_found(),
3474 }
3475}
3476
3477async fn preferences_form(
3479 State(state): State<AdminState>,
3480 Extension(shell): Extension<Shell>,
3481 Extension(user): Extension<AuthenticatedUser>,
3482 jar: CookieJar,
3483) -> Response {
3484 let account = account_security(&state, &user, &shell, &jar).await;
3485 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
3486 .into_iter()
3487 .filter(|i| i.per_operator)
3488 .map(|i| PreferenceLink {
3489 label: shell.tt(&i.label),
3490 description: shell.tt(&i.description),
3491 href: i.path(&state.admin_path),
3492 })
3493 .collect();
3494 render(preferences_view(
3495 &shell,
3496 &user,
3497 state.timezone,
3498 &state.default_locale,
3499 &offered_locales(&state.catalogs),
3500 account,
3501 state.auth.password_policy().min_length,
3502 items,
3503 ))
3504}
3505
3506struct PreferenceLink {
3508 label: String,
3509 description: String,
3510 href: String,
3511}
3512
3513#[derive(Deserialize)]
3514struct RevokeSessionForm {
3515 id: String,
3516}
3517
3518async fn session_revoke(
3520 State(state): State<AdminState>,
3521 Extension(user): Extension<AuthenticatedUser>,
3522 Extension(session): Extension<session::SessionHandle>,
3523 Form(form): Form<RevokeSessionForm>,
3524) -> Response {
3525 match state.auth.revoke_session(user.user.id, &form.id).await {
3526 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
3527 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
3528 Err(e) => {
3529 tracing::error!(error = %e, "revoking a session failed");
3530 session.push_flash(
3531 FlashLevel::Error,
3532 t!("That session could not be signed out."),
3533 );
3534 }
3535 }
3536 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3537}
3538
3539#[derive(Deserialize)]
3541struct MenuOrderForm {
3542 #[serde(default)]
3543 order: String,
3544}
3545
3546async fn menu_order_update(
3551 State(state): State<AdminState>,
3552 Extension(user): Extension<AuthenticatedUser>,
3553 Extension(session): Extension<session::SessionHandle>,
3554 headers: axum::http::HeaderMap,
3555 Form(form): Form<MenuOrderForm>,
3556) -> Response {
3557 let order: Vec<&str> = form
3558 .order
3559 .split(',')
3560 .map(str::trim)
3561 .filter(|k| !k.is_empty() && k.len() <= 200)
3562 .take(100)
3563 .collect();
3564 let result = if order.is_empty() {
3565 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
3566 .await
3567 } else {
3568 laterite_auth::store::set_user_preference(
3569 &state.db,
3570 user.user.id,
3571 nav::ORDER_PREFERENCE,
3572 &order.join(","),
3573 )
3574 .await
3575 };
3576 if let Err(e) = result {
3577 tracing::error!(error = %e, "storing the menu order failed");
3578 return render_error();
3579 }
3580 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
3581 return axum::http::StatusCode::NO_CONTENT.into_response();
3582 }
3583 session.push_flash(
3584 FlashLevel::Success,
3585 if order.is_empty() {
3586 t!("The menu is back in its default order.")
3587 } else {
3588 t!("Saved.")
3589 },
3590 );
3591 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3592}
3593
3594async fn session_revoke_others(
3595 State(state): State<AdminState>,
3596 Extension(user): Extension<AuthenticatedUser>,
3597 Extension(session): Extension<session::SessionHandle>,
3598 jar: CookieJar,
3599) -> Response {
3600 let token = jar
3601 .get(SESSION_COOKIE)
3602 .map(|c| c.value().to_string())
3603 .unwrap_or_default();
3604 match state.auth.sign_out_everywhere(user.user.id, &token).await {
3605 Ok(_) => session.push_flash(
3606 FlashLevel::Success,
3607 t!("Every other session was signed out."),
3608 ),
3609 Err(e) => {
3610 tracing::error!(error = %e, "signing out other sessions failed");
3611 session.push_flash(
3612 FlashLevel::Error,
3613 t!("Those sessions could not be signed out."),
3614 );
3615 }
3616 }
3617 (
3620 jar.remove(remember_removal(&state.admin_path)),
3621 Redirect::to(&format!("{}/preferences", state.admin_path)),
3622 )
3623 .into_response()
3624}
3625
3626#[derive(Deserialize)]
3627struct PasswordForm {
3628 current_password: String,
3629 new_password: String,
3630 confirm_password: String,
3631}
3632
3633async fn password_update(
3638 State(state): State<AdminState>,
3639 Extension(user): Extension<AuthenticatedUser>,
3640 Extension(session): Extension<session::SessionHandle>,
3641 Extension(ctx): Extension<RequestContext>,
3642 jar: CookieJar,
3643 Form(form): Form<PasswordForm>,
3644) -> Response {
3645 let back = format!("{}/preferences#password", state.admin_path);
3646 let refuse = |message: Text| {
3647 session.push_flash(FlashLevel::Error, message);
3648 Redirect::to(&back).into_response()
3649 };
3650 if form.new_password != form.confirm_password {
3651 return refuse(t!("The new passwords do not match."));
3652 }
3653 let min_length = state.auth.password_policy().min_length;
3654 if form.new_password.chars().count() < min_length {
3655 return refuse(t!(
3656 "A password needs at least {n} characters.",
3657 n = min_length as i64
3658 ));
3659 }
3660 let remembered_here = match jar.get(REMEMBER_COOKIE) {
3663 Some(cookie) => {
3664 state
3665 .auth
3666 .remember_holder(cookie.value())
3667 .await
3668 .ok()
3669 .flatten()
3670 == Some(user.user.id)
3671 }
3672 None => false,
3673 };
3674 let token = jar
3675 .get(SESSION_COOKIE)
3676 .map(|c| c.value().to_string())
3677 .unwrap_or_default();
3678 let changed = state
3679 .auth
3680 .change_own_password(
3681 user.user.id,
3682 &form.current_password,
3683 &form.new_password,
3684 &token,
3685 &ctx,
3686 )
3687 .await;
3688 match changed {
3689 Ok(()) => {}
3690 Err(laterite_auth::AuthError::InvalidCredentials) => {
3691 return refuse(t!("Your current password is not correct."));
3692 }
3693 Err(laterite_auth::AuthError::TooManyAttempts) => {
3694 return refuse(t!("Too many attempts. Try again later."));
3695 }
3696 Err(e) => {
3697 tracing::error!(error = %e, "changing a password failed");
3698 return refuse(t!("The password could not be changed."));
3699 }
3700 }
3701 session.push_flash_sticky(
3702 FlashLevel::Success,
3703 t!("Password changed. Every other device was signed out."),
3704 );
3705 let jar = if remembered_here {
3706 match state.auth.issue_remember(user.user.id).await {
3707 Ok(credential) => jar.add(remember_cookie(
3708 credential,
3709 &state.admin_path,
3710 state.secure_cookie,
3711 )),
3712 Err(e) => {
3713 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
3714 jar.remove(remember_removal(&state.admin_path))
3715 }
3716 }
3717 } else if jar.get(REMEMBER_COOKIE).is_some() {
3718 jar.remove(remember_removal(&state.admin_path))
3721 } else {
3722 jar
3723 };
3724 (
3725 jar,
3726 Redirect::to(&format!("{}/preferences", state.admin_path)),
3727 )
3728 .into_response()
3729}
3730
3731#[derive(Deserialize)]
3732struct PreferencesForm {
3733 timezone: String,
3734 #[serde(default)]
3736 locale: String,
3737}
3738
3739async fn preferences_update(
3740 State(state): State<AdminState>,
3741 Extension(user): Extension<AuthenticatedUser>,
3742 Extension(session): Extension<session::SessionHandle>,
3743 Form(form): Form<PreferencesForm>,
3744) -> Response {
3745 let back = format!("{}/preferences", state.admin_path);
3746 let offered = offered_locales(&state.catalogs);
3747 let tz = form.timezone.trim();
3749 let tz_stored = if tz.is_empty() {
3750 None
3751 } else if tz.parse::<Tz>().is_ok() {
3752 Some(tz)
3753 } else {
3754 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
3755 return Redirect::to(&back).into_response();
3756 };
3757 let loc = form.locale.trim();
3758 let loc_stored = if loc.is_empty() {
3759 None
3760 } else if offered.iter().any(|l| l == loc) {
3761 Some(loc)
3762 } else {
3763 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
3764 return Redirect::to(&back).into_response();
3765 };
3766 if state
3767 .auth
3768 .set_user_timezone(user.user.id, tz_stored)
3769 .await
3770 .is_err()
3771 {
3772 return render_error();
3773 }
3774 match state.auth.set_user_locale(user.user.id, loc_stored).await {
3775 Ok(()) => {
3776 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
3777 Redirect::to(&back).into_response()
3778 }
3779 Err(_) => render_error(),
3780 }
3781}
3782
3783#[allow(clippy::too_many_arguments)]
3787fn preferences_view(
3788 shell: &Shell,
3789 user: &AuthenticatedUser,
3790 default_tz: Tz,
3791 default_locale: &str,
3792 offered: &[String],
3793 account: AccountSecurity,
3794 min_password: usize,
3795 items: Vec<PreferenceLink>,
3796) -> PreferencesTemplate {
3797 let current = user.user.timezone.as_deref();
3798 let zones = TZ_VARIANTS
3799 .iter()
3800 .filter(|tz| zone_offered(tz.name()))
3801 .map(|tz| TzOption {
3802 name: tz.name().to_string(),
3803 selected: current == Some(tz.name()),
3804 })
3805 .collect();
3806 let current_locale = user.user.locale.as_deref();
3807 let locales = offered
3808 .iter()
3809 .map(|code| LocaleOption {
3810 name: locale_name(code),
3811 selected: current_locale == Some(code.as_str()),
3812 code: code.clone(),
3813 })
3814 .collect();
3815 PreferencesTemplate {
3816 shell: shell.clone(),
3817 zones,
3818 effective_tz: shell.tz.name().to_string(),
3819 default_tz: default_tz.name().to_string(),
3820 inherits: current.is_none(),
3821 locales,
3822 default_locale: locale_name(default_locale),
3823 inherits_locale: current_locale.is_none(),
3824 sessions: account.sessions,
3825 password_changed: account.password_changed,
3826 must_change: account.must_change,
3827 min_password: min_password as i64,
3828 items,
3829 }
3830}
3831
3832struct AccountSecurity {
3835 sessions: Vec<SessionRow>,
3836 password_changed: Option<String>,
3837 must_change: bool,
3839}
3840
3841async fn account_security(
3842 state: &AdminState,
3843 user: &AuthenticatedUser,
3844 shell: &Shell,
3845 jar: &CookieJar,
3846) -> AccountSecurity {
3847 let locale = list::date_locale(shell.locale());
3848 let password_changed = match state.auth.password_changed_at(user.user.id).await {
3849 Ok(at) => {
3850 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
3851 }
3852 Err(e) => {
3853 tracing::error!(error = %e, "reading the password change time failed");
3854 None
3855 }
3856 };
3857 AccountSecurity {
3858 sessions: session_rows(state, user, shell, jar).await,
3859 password_changed,
3860 must_change: state
3861 .auth
3862 .must_change_password(user.user.id)
3863 .await
3864 .unwrap_or(false),
3865 }
3866}
3867
3868async fn session_rows(
3871 state: &AdminState,
3872 user: &AuthenticatedUser,
3873 shell: &Shell,
3874 jar: &CookieJar,
3875) -> Vec<SessionRow> {
3876 let token = jar
3877 .get(SESSION_COOKIE)
3878 .map(|c| c.value().to_string())
3879 .unwrap_or_default();
3880 let locale = list::date_locale(shell.locale());
3881 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
3882 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
3883 };
3884 match state.auth.list_sessions(user.user.id, &token).await {
3885 Ok(sessions) => sessions
3886 .into_iter()
3887 .map(|s| SessionRow {
3888 id: s.id,
3889 started: stamp(s.created_at),
3890 last_seen: stamp(s.last_seen_at),
3891 expires: stamp(s.expires_at),
3892 device: clientip::describe(s.user_agent.as_deref())
3893 .or(s.user_agent)
3894 .unwrap_or_else(|| "-".to_string()),
3895 ip_address: s.ip_address.unwrap_or_default(),
3896 current: s.current,
3897 })
3898 .collect(),
3899 Err(e) => {
3900 tracing::error!(error = %e, "listing sessions failed");
3901 Vec::new()
3902 }
3903 }
3904}
3905
3906fn builtin_resources() -> Vec<Resource> {
3908 vec![
3909 Resource {
3910 base_path: "/users".to_string(),
3911 nav_label: "Administrators".into(),
3912 list: backend_users_list_config(),
3913 form: None,
3914 permission: Some("backend.manage_users".to_string()),
3915 import: None,
3916 palette: None,
3917 relations: Vec::new(),
3918 menu: nav::Placement::default(),
3919 },
3920 Resource {
3921 base_path: "/roles".to_string(),
3922 nav_label: "Roles".into(),
3923 list: roles_list_config(),
3924 form: None,
3927 permission: Some("backend.manage_roles".to_string()),
3928 import: None,
3929 palette: None,
3930 relations: Vec::new(),
3931 menu: nav::Placement::default(),
3932 },
3933 resource!("descriptors/audit-log.yaml"),
3934 resource!("descriptors/access-log.yaml"),
3935 resource!("descriptors/request-log.yaml"),
3936 ]
3937}
3938
3939fn builtin_settings() -> Vec<settings::SettingsItem> {
3943 vec![
3944 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
3945 .description("Manage backend administrator accounts.")
3946 .category("Users")
3947 .order(10)
3948 .icon("users")
3949 .permission("backend.manage_users")
3950 .link("/users"),
3951 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
3952 .description("Manage roles and their permissions.")
3953 .category("Users")
3954 .order(20)
3955 .icon("shield")
3956 .permission("backend.manage_roles")
3957 .link("/roles"),
3958 settings::brand::settings_item(),
3959 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
3960 .description("Enable or disable installed plugins.")
3961 .category("System")
3962 .order(10)
3963 .icon("plug")
3964 .permission(plugins::MANAGE_PERMISSION)
3965 .link("/plugins"),
3966 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
3967 .description("Review the record of administrative changes.")
3968 .category("System")
3969 .order(20)
3970 .icon("history")
3971 .permission("backend.view_audit_log")
3972 .link("/audit-log"),
3973 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
3974 .description("Review sign-ins, failures and lockouts.")
3975 .category("System")
3976 .order(30)
3977 .icon("log-in")
3978 .permission("backend.view_access_log")
3979 .link("/access-log"),
3980 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
3981 .description("Review the URLs the panel could not serve.")
3982 .category("System")
3983 .order(31)
3984 .icon("unlink")
3985 .permission("backend.view_request_log")
3986 .link("/request-log"),
3987 ]
3988}
3989
3990fn backend_users_list_config() -> list::ListConfig {
3991 list::ListConfig {
3992 entity: "backend_users".to_string(),
3993 title: "Administrators".into(),
3994 columns: vec![
3995 list::ListColumn::new("username", "Username"),
3996 list::ListColumn::new("email", "Email"),
3997 list::ListColumn::new("first_name", "First name"),
3998 list::ListColumn::new("last_name", "Last name"),
3999 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
4000 list::ListColumn::new("is_active", "Active").yes_no(),
4001 list::ListColumn::new("created_at", "Created").datetime(),
4002 ],
4003 order_by: "created_at".to_string(),
4004 creatable: true,
4007 edit_base: Some("/users".to_string()),
4008 filters: vec![
4009 list::ListFilter::boolean("is_active", "Active"),
4010 list::ListFilter::boolean("is_superuser", "Superuser"),
4011 ],
4012 ..Default::default()
4016 }
4017}
4018
4019fn roles_list_config() -> list::ListConfig {
4020 list::ListConfig {
4021 entity: "backend_roles".to_string(),
4022 title: "Roles".into(),
4023 columns: vec![
4024 list::ListColumn::new("code", "Code"),
4025 list::ListColumn::new("name", "Name"),
4026 list::ListColumn::new("created_at", "Created").datetime(),
4027 ],
4028 order_by: "created_at".to_string(),
4029 edit_base: Some("/roles".to_string()),
4030 creatable: true,
4031 deletable: true,
4032 ..Default::default()
4033 }
4034}
4035
4036#[derive(Template)]
4037#[template(path = "login.html")]
4038struct LoginTemplate {
4039 base: String,
4041 asset_urls: Arc<AssetUrls>,
4043 brand: String,
4044 error: Option<String>,
4045 notice: Option<String>,
4047 i18n: Translator,
4050}
4051
4052fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
4055 match urls.get(key) {
4056 Some(path) => format!("{base}/assets/{path}"),
4057 None => {
4058 debug_assert!(false, "asset key `{key}` is not registered");
4059 format!("{base}/assets/{key}")
4060 }
4061 }
4062}
4063
4064impl LoginTemplate {
4065 fn asset(&self, key: &str) -> String {
4066 preauth_asset(&self.asset_urls, &self.base, key)
4067 }
4068 fn t(&self, source: &str) -> String {
4069 self.i18n.t(&Text::dynamic(source))
4070 }
4071 fn locale(&self) -> &str {
4072 self.i18n.locale()
4073 }
4074}
4075
4076#[derive(Template)]
4077#[template(path = "dashboard.html")]
4078struct DashboardTemplate {
4079 shell: Shell,
4080 username: String,
4081}
4082
4083#[derive(Template)]
4084#[template(path = "setup.html")]
4085struct SetupTemplate {
4086 base: String,
4088 asset_urls: Arc<AssetUrls>,
4090 brand: String,
4091 zones: Vec<TzOption>,
4092 error: Option<String>,
4093 i18n: Translator,
4095 min_password: i64,
4097}
4098
4099impl SetupTemplate {
4100 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
4103 let mut text = Text::dynamic(source);
4104 for (name, value) in args {
4105 text = text.arg(*name, *value);
4106 }
4107 self.i18n.t(&text)
4108 }
4109
4110 fn asset(&self, key: &str) -> String {
4111 preauth_asset(&self.asset_urls, &self.base, key)
4112 }
4113 fn t(&self, source: &str) -> String {
4114 self.i18n.t(&Text::dynamic(source))
4115 }
4116 fn locale(&self) -> &str {
4117 self.i18n.locale()
4118 }
4119}
4120
4121#[derive(Template)]
4122#[template(path = "preferences.html")]
4123struct PreferencesTemplate {
4124 shell: Shell,
4125 zones: Vec<TzOption>,
4126 effective_tz: String,
4128 default_tz: String,
4130 inherits: bool,
4132 locales: Vec<LocaleOption>,
4133 default_locale: String,
4135 inherits_locale: bool,
4137 sessions: Vec<SessionRow>,
4139 password_changed: Option<String>,
4141 must_change: bool,
4143 min_password: i64,
4145 items: Vec<PreferenceLink>,
4147}
4148
4149struct SessionRow {
4151 id: String,
4152 started: String,
4153 last_seen: String,
4154 expires: String,
4155 device: String,
4158 ip_address: String,
4160 current: bool,
4161}
4162
4163struct TzOption {
4164 name: String,
4165 selected: bool,
4166}
4167
4168struct LocaleOption {
4169 code: String,
4171 name: String,
4173 selected: bool,
4174}
4175
4176#[derive(Clone)]
4178struct CrumbView {
4179 label: String,
4180 href: String,
4181}
4182
4183#[derive(Clone)]
4184struct NavView {
4185 key: String,
4187 label: String,
4188 path: String,
4189 active: bool,
4190 icon: String,
4193}
4194
4195pub(crate) fn render<T: Template>(template: T) -> Response {
4197 match template.render() {
4198 Ok(html) => Html(html).into_response(),
4199 Err(e) => AdminError::from(e).into_response(),
4201 }
4202}
4203
4204pub(crate) fn render_error() -> Response {
4205 error::masked_500()
4206}
4207
4208pub(crate) fn not_found() -> Response {
4209 AdminError::NotFound.into_response()
4210}
4211
4212pub(crate) fn forbidden() -> Response {
4213 AdminError::Forbidden.into_response()
4214}
4215
4216#[cfg(test)]
4217mod tests {
4218 use super::*;
4219
4220 fn write_form() -> form::FormConfig {
4221 form::FormConfig {
4222 enter: Default::default(),
4223 confirm_leave: true,
4224 focus: Default::default(),
4225 entity: "widgets".to_string(),
4226 title: "Widgets".into(),
4227 base_path: "/widgets".to_string(),
4228 id_field: "id".to_string(),
4229 fields: vec![],
4230 persist: None,
4231 timestamps: false,
4232 done_path: None,
4233 }
4234 }
4235
4236 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
4237 Resource {
4238 base_path: "/widgets".to_string(),
4239 nav_label: "Widgets".into(),
4240 list: backend_users_list_config(),
4241 form,
4242 permission: permission.map(str::to_string),
4243 import: None,
4244 palette: None,
4245 relations: Vec::new(),
4246 menu: nav::Placement::default(),
4247 }
4248 }
4249
4250 #[test]
4251 #[should_panic(expected = "must declare a permission")]
4252 fn write_resource_without_permission_aborts_boot() {
4253 let resource = resource_with(Some(write_form()), None);
4256 let _ = mount_resource(
4257 &resource,
4258 &field::builtin_registry(),
4259 &list::builtin_column_registry(),
4260 &std::collections::HashSet::new(),
4261 &persist::PersisterRegistry::new(),
4262 &[],
4263 );
4264 }
4265
4266 #[test]
4268 fn the_access_log_is_read_from_its_descriptor_file() {
4269 let access = builtin_resources()
4270 .into_iter()
4271 .find(|r| r.base_path == "/access-log")
4272 .expect("the access log is registered");
4273 assert_eq!(access.list.entity, "backend_access_log");
4274 assert_eq!(
4275 access.permission.as_deref(),
4276 Some("backend.view_access_log")
4277 );
4278 assert!(access.form.is_none(), "append-only");
4279 assert_eq!(
4280 access
4281 .list
4282 .columns
4283 .iter()
4284 .map(|c| c.field.as_str())
4285 .collect::<Vec<_>>(),
4286 [
4287 "created_at",
4288 "username_attempted",
4289 "event",
4290 "ip_address",
4291 "user_agent"
4292 ]
4293 );
4294 }
4295
4296 #[test]
4299 fn the_audit_log_is_read_from_its_descriptor_file() {
4300 let audit = builtin_resources()
4301 .into_iter()
4302 .find(|r| r.base_path == "/audit-log")
4303 .expect("the audit log is registered");
4304 assert_eq!(audit.list.entity, "backend_audit_log");
4305 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
4306 assert_eq!(
4307 audit
4308 .list
4309 .columns
4310 .iter()
4311 .map(|c| c.field.as_str())
4312 .collect::<Vec<_>>(),
4313 [
4314 "created_at",
4315 "actor_username",
4316 "action",
4317 "target_type",
4318 "target_id",
4319 "target_label"
4320 ],
4321 "in the order the file writes them"
4322 );
4323 assert_eq!(audit.list.columns[0].column_type, "datetime");
4324 assert_eq!(audit.list.per_page, 50);
4325 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
4326 assert!(audit.list.exportable, "evidence may leave the panel");
4327 assert!(audit.form.is_none(), "append-only: no form");
4328 assert!(!audit.list.deletable, "and nothing removes from it");
4329 }
4330
4331 #[test]
4332 #[should_panic(expected = "uses unregistered type `nope`")]
4333 fn unregistered_column_type_aborts_boot() {
4334 let mut resource = resource_with(None, None);
4335 let mut score = list::ListColumn::new("score", "Score");
4336 score.column_type = "nope".into();
4337 resource.list.columns.push(score);
4338 let _ = mount_resource(
4339 &resource,
4340 &field::builtin_registry(),
4341 &list::builtin_column_registry(),
4342 &std::collections::HashSet::new(),
4343 &persist::PersisterRegistry::new(),
4344 &[],
4345 );
4346 }
4347
4348 #[test]
4349 fn read_only_resource_may_omit_permission() {
4350 let resource = resource_with(None, None);
4352 let _ = mount_resource(
4353 &resource,
4354 &field::builtin_registry(),
4355 &list::builtin_column_registry(),
4356 &std::collections::HashSet::new(),
4357 &persist::PersisterRegistry::new(),
4358 &[],
4359 );
4360 }
4361
4362 #[test]
4363 fn shell_localizes_through_the_translator_and_falls_back() {
4364 let shell = Shell::test();
4366 assert_eq!(shell.t("Save"), "Save");
4367 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
4368 }
4369
4370 #[test]
4371 fn shell_tf_interpolates_integer_arguments() {
4372 let shell = Shell::test();
4373 assert_eq!(
4374 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
4375 "Page 2 of 5"
4376 );
4377 }
4378
4379 #[test]
4380 fn display_tz_prefers_a_valid_operator_preference() {
4381 assert_eq!(
4382 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
4383 Tz::Asia__Kolkata
4384 );
4385 }
4386
4387 #[test]
4388 fn display_tz_falls_back_when_unset_or_invalid() {
4389 let default = Tz::Europe__London;
4390 assert_eq!(resolve_display_tz(None, default), default);
4392 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
4394 assert_eq!(resolve_display_tz(Some(""), default), default);
4395 }
4396
4397 #[test]
4398 fn accept_language_orders_by_weight_and_drops_wildcard() {
4399 assert_eq!(
4400 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
4401 vec!["en-US", "en", "kn"]
4402 );
4403 assert_eq!(
4405 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
4406 vec!["kn", "en"]
4407 );
4408 }
4409
4410 #[test]
4411 fn locale_chain_prefers_operator_then_header_then_default() {
4412 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
4414 assert_eq!(
4417 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
4418 vec!["kn", "ta", "hi", "en"]
4419 );
4420 assert_eq!(
4422 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
4423 vec!["kn", "en", "ta"]
4424 );
4425 assert_eq!(
4427 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
4428 vec!["kn", "en"]
4429 );
4430 }
4431
4432 #[test]
4433 fn locale_chain_skips_unserveable_and_defaults_to_en() {
4434 let loaded = ["hi".to_string()];
4436 assert_eq!(
4439 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
4440 vec!["en"]
4441 );
4442 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
4444 assert_eq!(
4446 resolve_locale_chain(Some("hi"), None, "en", &loaded),
4447 vec!["hi", "en"]
4448 );
4449 assert_eq!(
4451 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
4452 vec![laterite_core::PSEUDO_LOCALE, "en"]
4453 );
4454 }
4455
4456 #[test]
4457 fn page_assets_dedupe_preserve_order_and_classify() {
4458 let mut reg = AssetRegistry::new();
4459 reg.insert(
4460 "a.js",
4461 AdminAsset {
4462 mime: "text/javascript; charset=utf-8",
4463 bytes: std::borrow::Cow::Borrowed(b"//"),
4464 },
4465 );
4466 reg.insert(
4467 "b.css",
4468 AdminAsset {
4469 mime: "text/css; charset=utf-8",
4470 bytes: std::borrow::Cow::Borrowed(b"body{}"),
4471 },
4472 );
4473 let urls = asset_urls(®);
4476 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
4477 assert_eq!(assets.len(), 2);
4478 assert_eq!(
4479 assets[0].url,
4480 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
4481 );
4482 assert!(!assets[0].css);
4483 assert_eq!(
4484 assets[1].url,
4485 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
4486 );
4487 assert!(assets[1].css);
4488 assert_ne!(assets[0].url, assets[1].url);
4491 }
4492
4493 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
4497 laterite_core::testing::connect_test(&[]).await
4498 }
4499
4500 #[tokio::test]
4501 async fn builtin_migrations_create_the_admin_tables() {
4502 let (db, _guard) = empty_db().await;
4503 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
4504 .await
4505 .unwrap();
4506 for table in ["backend_users", "settings"] {
4510 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
4511 .fetch_optional(&db.pool)
4512 .await;
4513 assert!(
4514 probe.is_ok(),
4515 "{table} should exist after builtin_migrations"
4516 );
4517 }
4518 }
4519
4520 #[tokio::test]
4521 async fn brand_setting_overrides_config_and_blank_falls_back() {
4522 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
4523 let state = AdminState {
4524 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
4525 db: db.clone(),
4526 nav: Arc::new(nav::NavTree::default()),
4527 settings: Arc::new(Vec::new()),
4528 permissions: Arc::new(builtin_permissions()),
4529 trusted_proxies: Arc::new(Vec::new()),
4530 icons: Arc::new(laterite_core::icons::IconSet::new()),
4531 sprite_url: Arc::from(""),
4532 admin_path: Arc::from("/admin"),
4533 secure_cookie: false,
4534 origin: Arc::from(""),
4535 timezone: Tz::UTC,
4536 default_locale: Arc::from("en"),
4537 catalogs: Arc::new(CatalogStore::default()),
4538 app_name: "Configured Name".to_string(),
4539 brand_cache: Arc::new(RwLock::new(None)),
4540 field_types: Arc::new(field::builtin_registry()),
4541 column_types: Arc::new(list::builtin_column_registry()),
4542 plugin_defined: Arc::new(laterite_core::Registry::new()),
4543 assets: Arc::new(builtin_assets()),
4544 asset_urls: Arc::new(asset_urls(&builtin_assets())),
4545 pickers: Arc::new(picker::PickerRegistry::new()),
4546 palette: Arc::new(Vec::new()),
4547 overrides: Arc::new(field::NoOverrides),
4548 };
4549
4550 assert_eq!(state.brand().await, "Configured Name");
4552
4553 settings::store::save(
4556 &db,
4557 &settings::BrandSetting {
4558 app_name: "Acme Corp".to_string(),
4559 },
4560 )
4561 .await
4562 .unwrap();
4563 state.invalidate_brand();
4564 assert_eq!(state.brand().await, "Acme Corp");
4565
4566 settings::store::save(
4568 &db,
4569 &settings::BrandSetting {
4570 app_name: " ".to_string(),
4571 },
4572 )
4573 .await
4574 .unwrap();
4575 state.invalidate_brand();
4576 assert_eq!(state.brand().await, "Configured Name");
4577 }
4578
4579 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
4580 settings::SettingsItem {
4581 code: code.to_string(),
4582 label: code.into(),
4583 description: String::new().into(),
4584 category: "General".into(),
4585 hint: None,
4586 confirm_leave: true,
4587 order: 1,
4588 icon: None,
4589 permission: permission.map(str::to_string),
4590 link: None,
4591 fields: Vec::new(),
4592 route: None,
4593 per_operator: false,
4594 }
4595 }
4596
4597 #[test]
4598 fn settings_visibility_respects_permissions() {
4599 let items = vec![
4600 settings_item("public", None),
4601 settings_item("gated", Some("backend.manage_users")),
4602 ];
4603
4604 let none = PermissionSet::new(false, Vec::<String>::new());
4606 let codes: Vec<String> = visible_settings(&items, &none)
4607 .into_iter()
4608 .map(|i| i.code)
4609 .collect();
4610 assert_eq!(codes, ["public"]);
4611
4612 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
4614 assert_eq!(visible_settings(&items, &granted).len(), 2);
4615
4616 let superuser = PermissionSet::new(true, Vec::<String>::new());
4618 assert_eq!(visible_settings(&items, &superuser).len(), 2);
4619 }
4620
4621 #[test]
4622 fn context_sidebar_follows_settings_links() {
4623 let items = builtin_settings();
4624 let superuser = PermissionSet::new(true, Vec::<String>::new());
4625 let tr = Translator::new("en");
4626 let tree = nav::NavTree::default();
4627 let sidebar = |path: &str| {
4628 resolve_nav_context(
4629 &tree,
4630 &items,
4631 "/admin",
4632 &superuser,
4633 None,
4634 path,
4635 &tr,
4636 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
4637 )
4638 .1
4639 };
4640 let active_path = |path: &str| -> Option<String> {
4641 sidebar(path)
4642 .into_iter()
4643 .flat_map(|g| g.items)
4644 .find(|i| i.active)
4645 .map(|i| i.path)
4646 };
4647
4648 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
4650 assert_eq!(
4651 active_path("/admin/roles/42/edit").as_deref(),
4652 Some("/admin/roles")
4653 );
4654 assert!(!sidebar("/admin/settings").is_empty());
4656 assert_eq!(active_path("/admin/settings"), None);
4657 assert_eq!(
4662 active_path("/admin/settings/branding").as_deref(),
4663 Some("/admin/settings/branding")
4664 );
4665 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
4668 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
4669 assert!(sidebar("/admin").is_empty());
4671 }
4672}
4673
4674#[cfg(test)]
4675mod session_cookie_tests {
4676 use super::*;
4677
4678 #[test]
4682 fn the_session_cookie_never_outlives_the_browser() {
4683 let cookie = session_cookie("tok".into(), "/admin", false);
4684 assert_eq!(cookie.max_age(), None);
4685 assert!(cookie.http_only().unwrap_or(false));
4686 assert_eq!(cookie.path(), Some("/admin"));
4687 }
4688
4689 #[test]
4692 fn the_remember_cookie_carries_its_own_lifetime() {
4693 let credential = laterite_auth::RememberCredential {
4694 cookie: "sel:ver".into(),
4695 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
4696 };
4697 let cookie = remember_cookie(credential, "/admin", true);
4698 let age = cookie.max_age().expect("a remember cookie must persist");
4699 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
4701 assert!(cookie.http_only().unwrap_or(false));
4702 assert!(cookie.secure().unwrap_or(false));
4703 }
4704
4705 #[test]
4707 fn the_removal_matches_the_cookie_it_clears() {
4708 let removal = remember_removal("/admin");
4709 assert_eq!(removal.name(), REMEMBER_COOKIE);
4710 assert_eq!(removal.path(), Some("/admin"));
4711 }
4712}