1mod audit;
18pub mod bootstrap;
19mod bulk;
20pub mod checklist;
21mod clientip;
22pub mod descriptor;
23pub mod editor;
24mod error;
25mod export;
26pub mod field;
27pub mod form;
28pub mod html;
29pub mod http_cache;
30mod icons;
31pub mod import;
32pub(crate) mod keyed;
33pub mod list;
34pub mod medialib;
35pub mod nav;
36pub(crate) mod palette;
37pub mod persist;
38pub mod picker;
39pub mod plugins;
40pub mod relation;
41pub(crate) mod requestlog;
42mod richtext;
43mod roles;
44mod routemap;
45pub mod routes;
46mod session;
47pub mod settings;
48mod sql;
49pub mod ui;
50mod upload;
51mod users;
52
53pub use axum;
61
62pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
63pub use error::AdminError;
64pub use session::{FlashLevel, SessionHandle};
65pub use upload::VerifiedUpload;
66
67use std::collections::HashMap;
68use std::sync::{Arc, RwLock};
69
70use askama::Template;
71use axum::body::Body;
72use axum::extract::{Path, Query, Request, State};
73use axum::http::{header, HeaderMap};
74use axum::middleware::{self, Next};
75use axum::response::{Html, IntoResponse, Redirect, Response};
76use axum::routing::{any, get, post};
77use axum::{Extension, Form, Router};
78use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
79use chrono_tz::{Tz, TZ_VARIANTS};
80use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
81use laterite_core::{t, CatalogStore, Db, Text, Translator};
82use serde::{Deserialize, Serialize};
83
84pub trait AdminRegistry {
90 fn add_resource(&mut self, resource: Resource);
92 fn add_permission(&mut self, permission: Permission);
94 fn add_settings(&mut self, item: settings::SettingsItem);
96 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
98 fn add_persister(&mut self, persister: persist::PersisterReg);
100 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
102 fn add_screen(&mut self, screen: routes::ScreenReg);
105 fn add_public_route(&mut self, route: routes::PublicRouteReg);
108 fn add_nav(&mut self, item: nav::NavItem);
110 fn add_resource_hook(&mut self, hook: ResourceHook);
112 fn add_editor_command(&mut self, command: editor::EditorCommand);
114 fn add_snippet_source(&mut self, source: std::sync::Arc<dyn editor::SnippetSource>);
116}
117
118impl AdminRegistry for laterite_core::Registry {
119 fn add_resource(&mut self, resource: Resource) {
120 self.add(resource);
121 }
122 fn add_permission(&mut self, permission: Permission) {
123 self.add(permission);
124 }
125 fn add_settings(&mut self, item: settings::SettingsItem) {
126 self.add(item);
127 }
128 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
129 self.add(source);
130 }
131 fn add_screen(&mut self, screen: routes::ScreenReg) {
132 self.add(screen);
133 }
134 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
135 self.add(route);
136 }
137 fn add_nav(&mut self, item: nav::NavItem) {
138 self.add(item);
139 }
140 fn add_resource_hook(&mut self, hook: ResourceHook) {
141 self.add(hook);
142 }
143 fn add_editor_command(&mut self, command: editor::EditorCommand) {
144 self.add(command);
145 }
146 fn add_snippet_source(&mut self, source: std::sync::Arc<dyn editor::SnippetSource>) {
147 self.add(editor::SnippetSourceReg(source));
148 }
149 fn add_persister(&mut self, persister: persist::PersisterReg) {
150 self.add(persister);
151 }
152 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
153 self.add(field_type);
154 }
155}
156
157#[derive(Clone)]
164pub struct ResourceHook {
165 pub target: String,
166 apply: Arc<dyn Fn(Resource) -> Resource + Send + Sync>,
167}
168
169impl ResourceHook {
170 pub fn new(
171 target: impl Into<String>,
172 apply: impl Fn(Resource) -> Resource + Send + Sync + 'static,
173 ) -> Self {
174 Self {
175 target: target.into(),
176 apply: Arc::new(apply),
177 }
178 }
179
180 pub fn apply(&self, resource: Resource) -> Resource {
182 (self.apply)(resource)
183 }
184}
185
186impl std::fmt::Debug for ResourceHook {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 write!(f, "ResourceHook({})", self.target)
189 }
190}
191
192pub(crate) const SESSION_COOKIE: &str = "laterite_session";
193const REMEMBER_COOKIE: &str = "laterite_remember";
197
198pub(crate) const ICON_SPRITE: &str = "icons.svg";
201
202#[derive(Clone)]
204pub(crate) struct AdminState {
205 auth: AuthService,
206 db: Db,
207 nav: Arc<nav::NavTree>,
208 nav_items: Arc<Vec<nav::NavItem>>,
211 settings: Arc<Vec<settings::SettingsItem>>,
212 permissions: Arc<Vec<Permission>>,
213 admin_path: Arc<str>,
217 cookie_path: Arc<str>,
221 storage_dir: Arc<str>,
223 signing_key: Arc<Vec<u8>>,
225 order: std::collections::BTreeMap<String, Vec<String>>,
227 secure_cookie: bool,
228 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
230 icons: Arc<laterite_core::icons::IconSet>,
232 sprite_url: Arc<str>,
234 origin: Arc<str>,
236 timezone: Tz,
237 default_locale: Arc<str>,
241 catalogs: Arc<CatalogStore>,
244 app_name: String,
247 brand_cache: Arc<RwLock<Option<String>>>,
251 asset_urls: Arc<AssetUrls>,
253 field_types: Arc<field::FieldRegistry>,
256 column_types: Arc<list::ColumnRegistry>,
259 plugin_defined: Arc<laterite_core::Registry>,
262 assets: Arc<AssetRegistry>,
264 pickers: Arc<picker::PickerRegistry>,
267 palette: Arc<Vec<palette::RecordSource>>,
270 overrides: Arc<dyn field::OverrideResolver>,
274}
275
276impl AdminState {
277 #[cfg(test)]
278 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
279 Self {
280 auth,
281 db,
282 nav: Arc::new(nav::NavTree::default()),
283 nav_items: Arc::new(Vec::new()),
284 settings: Arc::new(Vec::new()),
285 permissions: Arc::new(builtin_permissions()),
286 trusted_proxies: Arc::new(Vec::new()),
287 icons: Arc::new(laterite_core::icons::IconSet::new()),
288 sprite_url: Arc::from(""),
289 admin_path: Arc::from("/admin"),
290 cookie_path: Arc::from("/admin"),
291 storage_dir: Arc::from("storage"),
292 signing_key: Arc::new(random_key()),
293 order: std::collections::BTreeMap::new(),
294 secure_cookie: false,
295 origin: Arc::from(""),
296 timezone: Tz::UTC,
297 default_locale: Arc::from("en"),
298 catalogs: Arc::new(CatalogStore::default()),
299 app_name: "Laterite".to_string(),
300 brand_cache: Arc::new(RwLock::new(None)),
301 field_types: Arc::new(field::builtin_registry()),
302 column_types: Arc::new(list::builtin_column_registry()),
303 plugin_defined: Arc::new(laterite_core::Registry::new()),
304 assets: Arc::new(builtin_assets()),
305 asset_urls: Arc::new(asset_urls(&builtin_assets())),
306 pickers: Arc::new(picker::PickerRegistry::new()),
307 palette: Arc::new(Vec::new()),
308 overrides: Arc::new(field::NoOverrides),
309 }
310 }
311
312 async fn brand(&self) -> String {
316 {
317 let cached = self.brand_cache.read().unwrap().clone();
318 if let Some(name) = cached {
319 return name;
320 }
321 }
322 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
323 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
324 _ => self.app_name.clone(),
325 };
326 *self.brand_cache.write().unwrap() = Some(resolved.clone());
327 resolved
328 }
329
330 fn invalidate_brand(&self) {
332 *self.brand_cache.write().unwrap() = None;
333 }
334}
335
336#[derive(Clone)]
342#[non_exhaustive]
343pub struct AdminConfig {
344 pub secure_cookie: bool,
347 pub trusted_proxies: Vec<String>,
351 pub timezone: String,
355 pub locale: String,
359 pub app_name: String,
363 pub path: String,
367 pub origin: String,
372 pub storage_dir: String,
377 pub order: std::collections::BTreeMap<String, Vec<String>>,
380 pub menu: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
382 pub secret: Vec<u8>,
386}
387
388impl Default for AdminConfig {
389 fn default() -> Self {
390 Self {
391 secure_cookie: false,
392 trusted_proxies: Vec::new(),
393 timezone: "UTC".to_string(),
394 locale: "en".to_string(),
395 app_name: "Laterite".to_string(),
396 path: "/admin".to_string(),
397 origin: String::new(),
398 storage_dir: "storage".to_string(),
399 order: std::collections::BTreeMap::new(),
400 menu: std::collections::BTreeMap::new(),
401 secret: random_key(),
402 }
403 }
404}
405
406pub(crate) fn random_key() -> Vec<u8> {
408 use rand::RngCore;
409 let mut key = vec![0u8; 32];
410 rand::thread_rng().fill_bytes(&mut key);
411 key
412}
413
414#[derive(Clone)]
423pub struct Shell {
424 pub(crate) base: String,
428 brand: String,
432 nav: Vec<NavView>,
433 full_name: String,
434 initial: String,
435 tz: Tz,
439 i18n: Translator,
443 sidebar: Vec<settings::CategoryView>,
447 crumbs: Vec<CrumbView>,
450 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
452 pub(crate) sprite_url: Arc<str>,
455 site_url: String,
459 asset_urls: Arc<AssetUrls>,
463 pub(crate) csrf_token: String,
467 pub(crate) flash: Vec<FlashLine>,
471 pub(crate) assets: Vec<PageAsset>,
475}
476
477#[derive(Template)]
479#[template(path = "page.html")]
480struct PageTemplate {
481 title: String,
482 back: Option<(String, String)>,
484 actions: Option<html::Markup>,
486 body: html::Markup,
487 shell: Shell,
488}
489
490pub struct PageHead {
501 title: Text,
502 back: Option<(Text, String)>,
503 actions: Option<html::Markup>,
504 assets: Vec<&'static str>,
505}
506
507impl PageHead {
508 pub fn new(title: impl Into<Text>) -> Self {
509 Self {
510 title: title.into(),
511 back: None,
512 actions: None,
513 assets: Vec::new(),
514 }
515 }
516
517 pub fn assets(mut self, keys: &[&'static str]) -> Self {
521 for key in keys {
522 if !self.assets.contains(key) {
523 self.assets.push(key);
524 }
525 }
526 self
527 }
528
529 pub fn back(mut self, label: impl Into<Text>, href: impl Into<String>) -> Self {
531 self.back = Some((label.into(), href.into()));
532 self
533 }
534
535 pub fn actions(mut self, actions: html::Markup) -> Self {
538 self.actions = Some(actions);
539 self
540 }
541}
542
543#[derive(Clone)]
545pub(crate) struct PageAsset {
546 pub(crate) url: String,
547 pub(crate) css: bool,
548}
549
550#[derive(Clone)]
553pub(crate) struct FlashLine {
554 pub(crate) level: session::FlashLevel,
555 pub(crate) text: String,
556 pub(crate) sticky: bool,
558}
559
560impl Shell {
561 #[allow(clippy::too_many_arguments)]
562 fn new(
563 base: &str,
564 brand: String,
565 nav: &[nav::NavNode],
566 user: &AuthenticatedUser,
567 default_tz: Tz,
568 sidebar: Vec<settings::CategoryView>,
569 active_nav: Option<&str>,
570 csrf_token: String,
571 flash: Vec<session::Flash>,
572 i18n: Translator,
573 asset_urls: Arc<AssetUrls>,
574 site_url: String,
575 icon_set: Arc<laterite_core::icons::IconSet>,
576 sprite_url: Arc<str>,
577 ) -> Self {
578 let full_name = user.user.full_name();
579 let initial = full_name
580 .chars()
581 .next()
582 .map(|c| c.to_uppercase().to_string())
583 .unwrap_or_else(|| "?".to_string());
584 let flash = flash
587 .into_iter()
588 .map(|f| FlashLine {
589 sticky: f.sticky,
590 level: f.level,
591 text: i18n.t(&f.text),
592 })
593 .collect();
594 let nav: Vec<NavView> = nav
595 .iter()
596 .map(|n| NavView {
597 key: n.item.key.clone(),
598 label: i18n.t(&n.item.label),
599 path: nav::tab_path(n).unwrap_or_default().to_string(),
600 active: active_nav == Some(n.item.key.as_str()),
601 icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
602 })
603 .collect();
604 let mut crumbs: Vec<CrumbView> = nav
605 .iter()
606 .filter(|n: &&NavView| n.active)
607 .map(|n| CrumbView {
608 label: n.label.clone(),
609 href: n.path.clone(),
610 })
611 .collect();
612 if let Some(entry) = sidebar
613 .iter()
614 .flat_map(|g| g.items.iter())
615 .find(|i| i.active)
616 {
617 crumbs.push(CrumbView {
618 label: entry.label.clone(),
619 href: entry.path.clone(),
620 });
621 }
622 Shell {
623 asset_urls,
624 icon_set,
625 sprite_url,
626 site_url,
627 base: base.to_string(),
628 brand,
629 nav,
630 crumbs,
631 full_name,
632 initial,
633 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
634 i18n,
635 sidebar,
636 csrf_token,
637 flash,
638 assets: Vec::new(),
639 }
640 }
641
642 pub(crate) fn site_url(&self) -> &str {
644 &self.site_url
645 }
646
647 pub(crate) fn sidebar_anchored(&self) -> bool {
650 self.nav.iter().any(|n| n.active)
651 }
652
653 pub fn icon(&self, name: &str) -> String {
657 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
658 }
659
660 pub(crate) fn asset(&self, key: &str) -> String {
665 match self.asset_urls.get(key) {
666 Some(path) => format!("{}/assets/{path}", self.base),
667 None => {
668 debug_assert!(false, "asset key `{key}` is not registered");
669 format!("{}/assets/{key}", self.base)
670 }
671 }
672 }
673
674 pub fn t(&self, source: &str) -> String {
677 self.i18n.t(&Text::dynamic(source))
678 }
679
680 pub fn tt(&self, text: &Text) -> String {
683 self.i18n.t(text)
684 }
685
686 pub fn i18n(&self) -> &Translator {
689 &self.i18n
690 }
691
692 pub fn locale(&self) -> &str {
694 self.i18n.locale()
695 }
696
697 pub fn base(&self) -> &str {
699 &self.base
700 }
701
702 pub fn csrf_token(&self) -> &str {
706 &self.csrf_token
707 }
708
709 pub fn datetime(&self, utc: &str) -> String {
712 match chrono::DateTime::parse_from_rfc3339(utc) {
713 Ok(at) => at
714 .with_timezone(&self.tz)
715 .format("%Y-%m-%d %H:%M")
716 .to_string(),
717 Err(_) => utc.to_string(),
718 }
719 }
720
721 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
725 self.page_with(PageHead::new(title.clone()), body)
726 }
727
728 pub fn page_with(&self, head: PageHead, body: html::Markup) -> Response {
731 let mut shell = self.clone();
732 for key in &head.assets {
733 let url = shell.asset(key);
734 if !shell.assets.iter().any(|a| a.url == url) {
735 shell.assets.push(PageAsset {
736 url,
737 css: key.ends_with(".css"),
738 });
739 }
740 }
741 render(PageTemplate {
742 title: self.tt(&head.title),
743 back: head.back.map(|(label, href)| (self.tt(&label), href)),
744 actions: head.actions,
745 body,
746 shell,
747 })
748 }
749
750 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
754 let mut text = Text::dynamic(source);
755 for (name, value) in args {
756 text = text.arg(*name, *value);
757 }
758 self.i18n.t(&text)
759 }
760
761 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
764 let mut text = Text::dynamic(source);
765 for (name, value) in args {
766 text = text.arg(*name, *value);
767 }
768 self.i18n.t(&text)
769 }
770
771 #[cfg(test)]
772 pub(crate) fn test() -> Self {
773 Shell {
774 asset_urls: Arc::new(asset_urls(&builtin_assets())),
775 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
776 sprite_url: Arc::from("/admin/assets/icons.svg"),
777 site_url: "http://localhost".to_string(),
778 base: "/admin".to_string(),
779 brand: "Laterite".to_string(),
780 nav: Vec::new(),
781 full_name: "Test Operator".to_string(),
782 initial: "T".to_string(),
783 tz: Tz::UTC,
784 i18n: Translator::new("en"),
785 sidebar: Vec::new(),
786 crumbs: Vec::new(),
787 csrf_token: "test-csrf-token".to_string(),
788 flash: Vec::new(),
789 assets: Vec::new(),
790 }
791 }
792}
793
794fn settings_context(
801 visible: &[settings::SettingsItem],
802 admin_path: &str,
803 path: &str,
804) -> (bool, Option<String>) {
805 if path == format!("{admin_path}/settings") {
806 return (true, None);
807 }
808 let own = visible
814 .iter()
815 .filter(|i| i.link.is_none())
816 .find(|i| path == format!("{admin_path}{}", i.route()))
817 .map(|i| i.code.clone());
818 if own.is_some() {
819 return (true, own);
820 }
821 let linked = visible
822 .iter()
823 .filter_map(|i| {
824 i.link
825 .as_deref()
826 .map(|link| (format!("{admin_path}{link}"), &i.code))
827 })
828 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
829 .max_by_key(|(link, _)| link.len())
830 .map(|(_, code)| code.clone());
831 (linked.is_some(), linked)
832}
833
834#[allow(clippy::too_many_arguments)]
840#[derive(Default)]
843struct OwnMenu {
844 nav_order: Option<String>,
845 settings_order: Option<String>,
846 moves: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
847}
848
849impl OwnMenu {
850 async fn load(state: &AdminState, user_id: i64) -> Self {
851 let read = |key: String| async move {
852 laterite_auth::store::user_preference(&state.db, user_id, &key)
853 .await
854 .unwrap_or_default()
855 };
856 let nav_order = read(nav::order_preference(nav::TOP_HIERARCHY)).await;
857 let settings_order = read(nav::order_preference(settings::HIERARCHY)).await;
858 let moves = read(nav::MOVES_PREFERENCE.to_string())
859 .await
860 .map(|json| nav::operator_moves(&json))
861 .unwrap_or_default();
862 Self {
863 nav_order,
864 settings_order,
865 moves,
866 }
867 }
868}
869
870fn own_tree(
873 state: &AdminState,
874 own: &OwnMenu,
875) -> (Arc<nav::NavTree>, Arc<Vec<settings::SettingsItem>>) {
876 if own.moves.is_empty() {
877 return (Arc::clone(&state.nav), Arc::clone(&state.settings));
878 }
879 let mut items: Vec<nav::NavItem> = state.nav_items.as_ref().clone();
880 let mut settings: Vec<settings::SettingsItem> = state.settings.as_ref().clone();
881 nav::apply_moves(&mut items, &mut settings, &own.moves);
882 match nav::NavTree::build_with(items, &state.admin_path, &state.order) {
883 Ok(tree) => (Arc::new(tree), Arc::new(settings)),
884 Err(e) => {
885 tracing::warn!(error = %e, "an operator's menu moves could not be applied");
886 (Arc::clone(&state.nav), Arc::clone(&state.settings))
887 }
888 }
889}
890
891#[allow(clippy::too_many_arguments)]
892fn resolve_nav_context(
893 nav: &nav::NavTree,
894 items: &[settings::SettingsItem],
895 admin_path: &str,
896 perms: &PermissionSet,
897 own_order: Option<&str>,
898 path: &str,
899 tr: &Translator,
900 icons: icons::Icons<'_>,
901 order: &std::collections::BTreeMap<String, Vec<String>>,
902 own_settings_order: Option<&str>,
903) -> (
904 Vec<nav::NavNode>,
905 Vec<settings::CategoryView>,
906 Option<String>,
907) {
908 let mut visible_nav = nav.visible(&|code| perms.allows(code));
909 if let Some(order) = own_order {
910 nav::apply_order(&mut visible_nav, order);
911 }
912 let own_sequence = own_settings_order.map(laterite_core::arrange::parse_sequence);
913 let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
915 .into_iter()
916 .filter(|i| !i.per_operator)
917 .collect();
918 let (in_context, active) = settings_context(&visible, admin_path, path);
919 if in_context {
920 let sidebar = settings::sidebar_groups(
921 &visible,
922 admin_path,
923 active.as_deref(),
924 tr,
925 icons,
926 order.get(settings::HIERARCHY).map(Vec::as_slice),
927 own_sequence.as_deref(),
928 );
929 return (visible_nav, sidebar, Some("settings".to_string()));
930 }
931 let active_top = nav::active_top(&visible_nav, admin_path, path);
932 let sidebar = active_top
933 .map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
934 .unwrap_or_default();
935 let active_key = active_top.map(|node| node.item.key.clone());
936 (visible_nav, sidebar, active_key)
937}
938
939fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
943 preference
944 .and_then(|name| name.parse::<Tz>().ok())
945 .unwrap_or(default_tz)
946}
947
948fn zone_offered(name: &str) -> bool {
950 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
951}
952
953fn base_lang(tag: &str) -> String {
956 tag.split(['-', '_'])
957 .next()
958 .unwrap_or(tag)
959 .to_ascii_lowercase()
960}
961
962fn is_serveable(base: &str, serveable: &[String]) -> bool {
968 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
969}
970
971fn default_locale(configured: &str, serveable: &[String]) -> String {
974 let base = base_lang(configured);
975 if is_serveable(&base, serveable) {
976 base
977 } else {
978 "en".to_string()
979 }
980}
981
982fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
986 let mut out = vec!["en".to_string()];
987 out.extend(catalogs.locales());
988 out
989}
990
991fn locale_name(code: &str) -> String {
996 if code == "en" {
997 "English".to_string()
998 } else {
999 code.to_string()
1000 }
1001}
1002
1003fn parse_accept_language(header: &str) -> Vec<String> {
1006 let mut items: Vec<(f32, String)> = header
1007 .split(',')
1008 .filter_map(|part| {
1009 let mut bits = part.split(';');
1010 let tag = bits.next()?.trim();
1011 if tag.is_empty() || tag == "*" {
1012 return None;
1013 }
1014 let q = bits
1015 .find_map(|p| p.trim().strip_prefix("q="))
1016 .and_then(|q| q.parse::<f32>().ok())
1017 .unwrap_or(1.0);
1018 Some((q, tag.to_string()))
1019 })
1020 .collect();
1021 items.sort_by(|a, b| b.0.total_cmp(&a.0));
1023 items.into_iter().map(|(_, tag)| tag).collect()
1024}
1025
1026fn resolve_locale_chain(
1032 preference: Option<&str>,
1033 accept_language: Option<&str>,
1034 default: &str,
1035 serveable: &[String],
1036) -> Vec<String> {
1037 let mut chain: Vec<String> = Vec::new();
1038 let consider = |tag: &str, chain: &mut Vec<String>| {
1039 let base = base_lang(tag);
1040 if is_serveable(&base, serveable) && !chain.contains(&base) {
1041 chain.push(base);
1042 }
1043 };
1044 if let Some(pref) = preference {
1045 consider(pref, &mut chain);
1046 }
1047 if let Some(header) = accept_language {
1048 for tag in parse_accept_language(header) {
1049 consider(&tag, &mut chain);
1050 }
1051 }
1052 consider(default, &mut chain);
1053 consider("en", &mut chain);
1056 chain
1057}
1058
1059fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
1063 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
1064 let serveable = state.catalogs.locales();
1065 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
1066 Translator::with_chain(chain, state.catalogs.clone())
1067}
1068
1069#[derive(Serialize)]
1077#[non_exhaustive]
1078pub struct Resource {
1079 pub base_path: String,
1085 pub nav_label: Text,
1087 pub list: list::ListConfig,
1088 pub form: Option<form::FormConfig>,
1089 pub permission: Option<String>,
1096 pub import: Option<ImportConfig>,
1099 pub palette: Option<String>,
1102 pub relations: Vec<relation::RelationConfig>,
1105 pub menu: nav::Placement,
1108}
1109
1110#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1113#[non_exhaustive]
1114pub struct ImportConfig {
1115 #[serde(default)]
1118 pub permission: Option<String>,
1119}
1120
1121impl ImportConfig {
1122 pub fn new() -> Self {
1123 Self::default()
1124 }
1125
1126 pub fn permission(mut self, code: impl Into<String>) -> Self {
1129 self.permission = Some(code.into());
1130 self
1131 }
1132}
1133
1134#[derive(Clone, Serialize)]
1139#[non_exhaustive]
1140pub struct Permission {
1141 pub code: String,
1142 pub label: Text,
1144 pub group: Text,
1145 pub default_roles: Vec<String>,
1148 pub renamed_from: Vec<String>,
1151}
1152
1153pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
1154
1155impl Permission {
1156 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
1159 Self {
1160 code: code.into(),
1161 label: label.into(),
1162 group: group.into(),
1163 default_roles: Vec::new(),
1164 renamed_from: Vec::new(),
1165 }
1166 }
1167
1168 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
1174 where
1175 I: IntoIterator<Item = S>,
1176 S: Into<String>,
1177 {
1178 self.renamed_from = codes.into_iter().map(Into::into).collect();
1179 self
1180 }
1181
1182 pub fn roles<I, S>(mut self, roles: I) -> Self
1185 where
1186 I: IntoIterator<Item = S>,
1187 S: Into<String>,
1188 {
1189 self.default_roles = roles.into_iter().map(Into::into).collect();
1190 self
1191 }
1192}
1193
1194pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
1200 let permissions: Vec<Permission> = builtin_permissions()
1201 .into_iter()
1202 .chain(contributed.iter().cloned())
1203 .collect();
1204 let mut out: Vec<(String, String)> = Vec::new();
1205 for permission in &permissions {
1206 for old in &permission.renamed_from {
1207 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
1208 panic!(
1209 "permissions `{taken}` and `{}` both claim the old code `{old}`",
1210 permission.code
1211 );
1212 }
1213 if permissions.iter().any(|p| &p.code == old) {
1214 panic!(
1215 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
1216 permission.code
1217 );
1218 }
1219 out.push((old.clone(), permission.code.clone()));
1220 }
1221 }
1222 out
1223}
1224
1225pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
1231 let permissions: Vec<Permission> = builtin_permissions()
1234 .into_iter()
1235 .chain(contributed.iter().cloned())
1236 .collect();
1237 let held = |role: &str| -> Vec<String> {
1238 permissions
1239 .iter()
1240 .filter(|p| {
1241 if p.default_roles.is_empty() {
1242 role == ROLE_ADMIN
1243 } else {
1244 p.default_roles.iter().any(|r| r == role)
1245 }
1246 })
1247 .map(|p| p.code.clone())
1248 .collect()
1249 };
1250 vec![
1251 laterite_auth::store::SystemRole {
1252 code: ROLE_ADMIN,
1253 name: "Administrator",
1254 description: "Administers the panel itself: operators, roles and settings.",
1255 permissions: held(ROLE_ADMIN),
1256 },
1257 laterite_auth::store::SystemRole {
1258 code: ROLE_EDITOR,
1259 name: "Editor",
1260 description: "Works with the content the application declares.",
1261 permissions: held(ROLE_EDITOR),
1262 },
1263 ]
1264}
1265
1266impl Resource {
1267 pub fn new(
1272 base_path: impl Into<String>,
1273 nav_label: impl Into<Text>,
1274 list: list::ListConfig,
1275 ) -> Self {
1276 Self {
1277 base_path: base_path.into(),
1278 nav_label: nav_label.into(),
1279 list,
1280 form: None,
1281 permission: None,
1282 import: None,
1283 palette: None,
1284 relations: Vec::new(),
1285 menu: nav::Placement::default(),
1286 }
1287 }
1288
1289 pub fn palette(mut self, source: impl Into<String>) -> Self {
1292 self.palette = Some(source.into());
1293 self
1294 }
1295
1296 pub fn menu(mut self, placement: nav::Placement) -> Self {
1298 self.menu = placement;
1299 self
1300 }
1301
1302 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1304 self.relations.push(relation);
1305 self
1306 }
1307
1308 pub fn import(mut self, import: ImportConfig) -> Self {
1310 self.import = Some(import);
1311 self
1312 }
1313
1314 pub fn form(mut self, form: form::FormConfig) -> Self {
1317 self.form = Some(form);
1318 self
1319 }
1320
1321 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1323 self.permission = Some(permission.into());
1324 self
1325 }
1326}
1327
1328struct SystemRoleGuard;
1334
1335#[laterite_core::strata::async_trait]
1336impl laterite_core::ModelListener for SystemRoleGuard {
1337 async fn before_delete(
1338 &self,
1339 cx: &mut laterite_core::SaveCx<'_>,
1340 rec: &laterite_core::Record,
1341 ) -> Result<(), Text> {
1342 let Some(id) = rec.id() else {
1343 return Ok(());
1344 };
1345 let backend = cx.backend();
1347 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1348 Ok(true) => Err(t!(
1349 "The framework maintains this role; it cannot be deleted."
1350 )),
1351 Err(_) => Err(t!(
1353 "Could not check whether this role is maintained by the framework."
1354 )),
1355 Ok(false) => Ok(()),
1356 }
1357 }
1358}
1359
1360fn builtin_permissions() -> Vec<Permission> {
1363 let mut out = vec![
1366 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1367 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1368 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1369 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1370 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1371 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1372 Permission::new(
1373 "backend.view_request_log",
1374 "View the request log",
1375 "Backend",
1376 ),
1377 ];
1378 out.extend(medialib::permissions());
1379 out
1380}
1381
1382pub fn descriptor_sources() -> Vec<String> {
1388 let mut out = Vec::new();
1389 out.extend(laterite_core::collect_sources(&builtin_resources()));
1390 out.extend(laterite_core::collect_sources(&builtin_settings()));
1391 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1392 out.sort();
1393 out.dedup();
1394 out
1395}
1396
1397pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1412 builtin_modules().iter().map(|m| m.migrations()).collect()
1413}
1414
1415pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1419 vec![
1420 Box::new(laterite_auth::AuthModule),
1421 Box::new(settings::SettingsModule),
1422 Box::new(requestlog::RequestLogModule),
1423 Box::new(plugins::PluginsModule),
1424 Box::new(laterite_media::MediaModule),
1425 Box::new(medialib::MediaLibraryModule),
1426 Box::new(import::ImportModule),
1427 ]
1428}
1429
1430#[derive(Default)]
1454pub struct Contributions {
1455 pub resources: Vec<Resource>,
1457 pub settings: Vec<settings::SettingsItem>,
1459 pub permissions: Vec<Permission>,
1461 pub picker_sources: Vec<picker::PickerSourceReg>,
1463 pub persisters: Vec<persist::PersisterReg>,
1465 pub listeners: Vec<laterite_core::ModelListenerReg>,
1467 pub field_types: Vec<field::FieldTypeReg>,
1471 pub column_types: Vec<list::ColumnTypeReg>,
1472 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1475 pub screens: Vec<routes::ScreenReg>,
1477 pub public_routes: Vec<routes::PublicRouteReg>,
1479 pub nav: Vec<nav::NavItem>,
1482 pub resource_hooks: Vec<ResourceHook>,
1485 pub plugin_defined: Arc<laterite_core::Registry>,
1490}
1491
1492pub fn router(
1493 auth: AuthService,
1494 db: Db,
1495 contributions: Contributions,
1496 config: AdminConfig,
1497 catalogs: Arc<CatalogStore>,
1498) -> Router {
1499 let Contributions {
1500 resources: app_resources,
1501 settings: app_settings,
1502 permissions: app_permissions,
1503 picker_sources: app_picker_sources,
1504 persisters: app_persisters,
1505 listeners: app_listeners,
1506 field_types: app_field_types,
1507 column_types: app_column_types,
1508 icons: app_icons,
1509 screens: app_screens,
1510 public_routes: app_public,
1511 nav: app_nav,
1512 resource_hooks,
1513 plugin_defined,
1514 } = contributions;
1515 let mut app_screens = app_screens;
1518 app_screens.push(medialib::screen());
1519 let mut app_public = app_public;
1520 app_public.push(medialib::serve_route());
1521 let admin_path = normalize_path(&config.path);
1522
1523 let mut resources = builtin_resources();
1524 let mut app_resources = app_resources;
1525 for hook in resource_hooks {
1528 let found = resources
1529 .iter_mut()
1530 .chain(app_resources.iter_mut())
1531 .find(|r| r.base_path == hook.target);
1532 match found {
1533 Some(resource) => {
1534 let changed = hook.apply(std::mem::replace(
1535 resource,
1536 Resource::new("", "", list::ListConfig::default()),
1537 ));
1538 *resource = changed;
1539 }
1540 None => panic!(
1541 "a resource hook targets `{}`, which no module declared",
1542 hook.target
1543 ),
1544 }
1545 }
1546 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1550 prefix_resource(&admin_path, resource);
1551 }
1552 for resource in app_resources.iter_mut() {
1556 if let Some(import) = &resource.import {
1557 let permission = import
1558 .permission
1559 .clone()
1560 .or_else(|| resource.permission.clone())
1561 .unwrap_or_else(|| {
1562 panic!(
1563 "admin resource `{}` declares `import` but no permission; importing \
1564 writes records, so it must be gated",
1565 resource.base_path
1566 )
1567 });
1568 resource.list.import = Some(routes::feature_link(
1569 &resource.base_path,
1570 "import",
1571 &permission,
1572 ));
1573 }
1574 }
1575
1576 let mut settings = builtin_settings();
1577 settings.extend(app_settings);
1578 let mut app_listeners = app_listeners;
1581 app_listeners.insert(
1582 0,
1583 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1584 );
1585
1586 let mut permissions = builtin_permissions();
1587 permissions.extend(app_permissions);
1588 let permission_codes: std::collections::HashSet<String> =
1590 permissions.iter().map(|p| p.code.clone()).collect();
1591
1592 let mut nav_items = vec![
1598 nav::NavItem::new("dashboard", "Dashboard", "")
1599 .icon("layout-dashboard")
1600 .slot(laterite_core::arrange::slot::HOME),
1601 nav::NavItem::new("settings", "Settings", "/settings")
1602 .icon("settings")
1603 .slot(laterite_core::arrange::slot::SYSTEM),
1604 ];
1605 nav_items.extend(app_nav);
1606 for resource in &app_resources {
1607 if resource.menu.hidden {
1608 continue;
1609 }
1610 let unprefixed = resource
1611 .base_path
1612 .strip_prefix(admin_path.as_str())
1613 .unwrap_or(&resource.base_path)
1614 .to_string();
1615 let mut item = nav::NavItem::new(
1616 format!("resource:{unprefixed}"),
1617 resource.nav_label.clone(),
1618 unprefixed,
1619 )
1620 .slot(resource.menu.slot)
1621 .order(resource.menu.order);
1622 item.icon = resource.menu.icon.clone();
1623 item.permission = resource.permission.clone();
1624 item.under = resource.menu.under.clone();
1625 item.after = resource.menu.after.clone();
1626 item.before = resource.menu.before.clone();
1627 nav_items.push(item);
1628 }
1629 for reg in &app_screens {
1630 let Some(label) = ®.nav_label else {
1631 continue;
1632 };
1633 if reg.menu.hidden {
1634 continue;
1635 }
1636 let mut item = nav::NavItem::new(
1637 format!("screen:{}", reg.base_path),
1638 label.clone(),
1639 reg.base_path.clone(),
1640 )
1641 .permission(reg.permission.clone())
1642 .slot(reg.menu.slot)
1643 .order(reg.menu.order);
1644 item.icon = reg.menu.icon.clone();
1645 item.under = reg.menu.under.clone();
1646 item.after = reg.menu.after.clone();
1647 item.before = reg.menu.before.clone();
1648 nav_items.push(item);
1649 }
1650 nav::apply_moves(&mut nav_items, &mut settings, &config.menu);
1653 let nav = nav::NavTree::build_with(nav_items.clone(), &admin_path, &config.order)
1654 .unwrap_or_else(|e| panic!("admin menu: {e}"));
1655 nav.log_arrangement();
1656 settings::log_arrangement(
1657 &settings,
1658 config.order.get(settings::HIERARCHY).map(Vec::as_slice),
1659 );
1660 let palette_sources: Vec<palette::RecordSource> = app_resources
1663 .iter()
1664 .filter_map(|r| {
1665 r.palette.as_ref().map(|source| palette::RecordSource {
1666 label: r.nav_label.clone(),
1667 source: source.clone(),
1668 base_path: r.base_path.clone(),
1669 permission: r.permission.clone(),
1670 })
1671 })
1672 .collect();
1673 resources.extend(app_resources);
1674
1675 let app_name = if config.app_name.trim().is_empty() {
1676 "Laterite".to_string()
1677 } else {
1678 config.app_name.clone()
1679 };
1680
1681 let mut pickers = picker::PickerRegistry::new();
1684 for reg in app_picker_sources {
1685 let name = reg.name.clone();
1686 if !field::is_name(&name, true) {
1687 panic!("invalid picker source name `{name}`");
1688 }
1689 if pickers.insert(name.clone(), reg).is_some() {
1690 panic!("duplicate picker source `{name}`");
1691 }
1692 }
1693
1694 for source in &palette_sources {
1698 if !pickers.contains_key(&source.source) {
1699 panic!(
1700 "admin resource `{}` names palette source `{}`, which no module registered",
1701 source.base_path, source.source
1702 );
1703 }
1704 }
1705 let pickers = Arc::new(pickers);
1706 let mut field_types = field::builtin_registry();
1707 field_types.insert(
1708 "reference".to_string(),
1709 Arc::new(field::RefPickerField::new(pickers.clone())),
1710 );
1711
1712 let mut persisters = persist::PersisterRegistry::new();
1715 for reg in app_persisters {
1716 let name = reg.name.clone();
1717 if !field::is_name(&name, true) {
1718 panic!("invalid persister name `{name}`");
1719 }
1720 if persisters.insert(name.clone(), reg.persister).is_some() {
1721 panic!("duplicate persister `{name}`");
1722 }
1723 }
1724
1725 for reg in app_field_types {
1731 let name = reg.name().to_string();
1732 if !field::is_name(&name, true) {
1733 panic!("invalid field type name `{name}`");
1734 }
1735 if field_types.insert(name.clone(), reg.field_type).is_some() {
1736 panic!("duplicate field type `{name}`");
1737 }
1738 }
1739
1740 let mut icon_set = laterite_core::icons::IconSet::new();
1744 for (owner, reg) in app_icons {
1745 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1746 panic!("module `{owner}` contributed an invalid icon: {e}");
1747 }
1748 }
1749
1750 let mut assets = builtin_assets();
1753 assets.insert(
1754 ICON_SPRITE,
1755 AdminAsset {
1756 mime: "image/svg+xml",
1757 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1758 },
1759 );
1760
1761 for item in &settings {
1765 icons::require(
1766 &icon_set,
1767 &format!("settings item `{}`", item.code),
1768 item.icon.as_deref(),
1769 );
1770 }
1771 for node in nav
1772 .top
1773 .iter()
1774 .flat_map(|n| std::iter::once(n).chain(n.children.iter()))
1775 {
1776 icons::require(
1777 &icon_set,
1778 &format!("menu entry `{}`", node.item.key),
1779 node.item.icon.as_deref(),
1780 );
1781 }
1782
1783 let mut column_types = list::builtin_column_registry();
1787 for reg in app_column_types {
1788 let name = reg.name().to_string();
1789 if !field::is_name(&name, true) {
1790 panic!("invalid column type name `{name}`");
1791 }
1792 if column_types.insert(name.clone(), reg.column_type).is_some() {
1793 panic!("duplicate column type `{name}`");
1794 }
1795 }
1796
1797 let settings = Arc::new(settings);
1798 let asset_urls_arc = Arc::new(asset_urls(&assets));
1800 let sprite_url: Arc<str> = Arc::from(
1801 asset_urls_arc
1802 .get(ICON_SPRITE)
1803 .map(|p| format!("{admin_path}/assets/{p}"))
1804 .unwrap_or_default()
1805 .as_str(),
1806 );
1807 let icons_arc = Arc::new(icon_set);
1808 let state = AdminState {
1809 auth,
1810 db,
1811 nav: Arc::new(nav),
1812 nav_items: Arc::new(nav_items),
1813 settings: settings.clone(),
1814 permissions: Arc::new(permissions),
1815 admin_path: Arc::from(admin_path.as_str()),
1816 secure_cookie: config.secure_cookie,
1817 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1818 icons: icons_arc.clone(),
1819 sprite_url: sprite_url.clone(),
1820 origin: Arc::from(config.origin.trim_end_matches('/')),
1821 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1822 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1823 catalogs,
1824 app_name,
1825 brand_cache: Arc::new(RwLock::new(None)),
1826 field_types: Arc::new(field_types),
1827 column_types: Arc::new(column_types),
1828 plugin_defined,
1829 storage_dir: Arc::from(config.storage_dir.trim_end_matches('/')),
1830 signing_key: Arc::new(config.secret.clone()),
1831 order: config.order.clone(),
1832 cookie_path: if app_public.iter().any(|r| r.is_site()) {
1833 Arc::from("/")
1834 } else {
1835 Arc::from(admin_path.as_str())
1836 },
1837 assets: Arc::new(assets),
1838 asset_urls: asset_urls_arc.clone(),
1839 pickers,
1840 palette: Arc::new(palette_sources),
1841 overrides: Arc::new(field::NoOverrides),
1842 };
1843
1844 let mut protected = Router::new().route(&admin_path, get(dashboard));
1845 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
1849 for resource in &resources {
1850 let (mounted, prepared) = mount_resource(
1851 resource,
1852 &state.field_types,
1853 &state.column_types,
1854 &permission_codes,
1855 &persisters,
1856 &app_listeners,
1857 );
1858 protected = protected.merge(mounted);
1859 if let Some(prepared) = prepared {
1860 let key = resource
1861 .base_path
1862 .strip_prefix(admin_path.as_str())
1863 .unwrap_or(&resource.base_path);
1864 forms.insert(key.to_string(), prepared);
1865 }
1866 }
1867 let forms: form::Forms = Arc::new(forms);
1868 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
1872 .plugin_defined
1873 .items::<laterite_media::DiskReg>()
1874 .into_iter()
1875 .find(|d| d.name == import::DISK)
1876 .map(|d| d.driver.clone())
1877 .unwrap_or_else(|| {
1878 Arc::new(laterite_media::LocalDisk::new(format!(
1879 "{}/{}",
1880 config.storage_dir.trim_end_matches('/'),
1881 import::DISK
1882 )))
1883 });
1884 for resource in &resources {
1885 let Some(link) = &resource.list.import else {
1886 continue;
1887 };
1888 let key = resource
1889 .base_path
1890 .strip_prefix(admin_path.as_str())
1891 .unwrap_or(&resource.base_path)
1892 .to_string();
1893 let prepared = forms.get(&key).unwrap_or_else(|| {
1894 panic!(
1895 "admin resource `{}` declares `import` but has no form to import through",
1896 resource.base_path
1897 )
1898 });
1899 let feature = import::ImportFeature {
1900 disk: private_disk.clone(),
1901 };
1902 let base = link.href.clone();
1903 let ctx = routes::RouteCtx::new(
1904 state.db.clone(),
1905 state.auth.clone(),
1906 state.field_types.clone(),
1907 state.overrides.clone(),
1908 state.signing_key.clone(),
1909 &state.storage_dir,
1910 &base,
1911 &admin_path,
1912 &state.origin,
1913 state.plugin_defined.clone(),
1914 state.auth.events().clone(),
1915 forms.clone(),
1916 );
1917 let info = routes::ResourceInfo::new(
1918 key,
1919 resource.base_path.clone(),
1920 resource.nav_label.clone(),
1921 link.permission.clone(),
1922 form::FormHandle::new(state.db.clone(), prepared.clone()),
1923 );
1924 protected = protected.merge(guard_with_permission(
1925 Router::new()
1926 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
1927 &link.permission,
1928 ));
1929 }
1930 for reg in &app_screens {
1934 let base = format!("{admin_path}{}", reg.base_path);
1935 let ctx = routes::RouteCtx::new(
1936 state.db.clone(),
1937 state.auth.clone(),
1938 state.field_types.clone(),
1939 state.overrides.clone(),
1940 state.signing_key.clone(),
1941 &state.storage_dir,
1942 &base,
1943 &admin_path,
1944 &state.origin,
1945 state.plugin_defined.clone(),
1946 state.auth.events().clone(),
1947 forms.clone(),
1948 );
1949 protected = protected.merge(guard_with_permission(
1950 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1951 ®.permission,
1952 ));
1953 }
1954
1955 protected = protected.merge(guard_with_permission(
1958 Router::new()
1959 .route(
1960 &format!("{admin_path}/roles/new"),
1961 get(roles::new_form).post(roles::create),
1962 )
1963 .route(
1964 &format!("{admin_path}/roles/{{id}}/edit"),
1965 get(roles::edit_form).post(roles::update),
1966 ),
1967 "backend.manage_roles",
1968 ));
1969 protected = protected.merge(guard_with_permission(
1972 Router::new()
1973 .route(
1974 &format!("{admin_path}/users/new"),
1975 get(users::new_form).post(users::create),
1976 )
1977 .route(
1978 &format!("{admin_path}/users/{{id}}/edit"),
1979 get(users::edit_form).post(users::update),
1980 )
1981 .route(
1982 &format!("{admin_path}/users/{{id}}/active"),
1983 post(users::set_active),
1984 )
1985 .route(
1986 &format!("{admin_path}/users/{{id}}/password"),
1987 post(users::reset_password),
1988 )
1989 .route(
1990 &format!("{admin_path}/users/{{id}}/unlock"),
1991 post(users::unlock),
1992 ),
1993 "backend.manage_users",
1994 ));
1995 protected = protected.merge(guard_with_permission(
1998 Router::new()
1999 .route(&format!("{admin_path}/plugins"), get(plugins::index))
2000 .route(
2001 &format!("{admin_path}/plugins/toggle"),
2002 post(plugins::toggle),
2003 ),
2004 plugins::MANAGE_PERMISSION,
2005 ));
2006 protected = protected
2007 .route(&format!("{admin_path}/settings"), get(settings_index))
2008 .route(
2009 &format!("{admin_path}/preferences"),
2010 get(preferences_form).post(preferences_update),
2011 )
2012 .route(
2013 &format!("{admin_path}/preferences/password"),
2014 post(password_update),
2015 )
2016 .route(
2017 &format!("{admin_path}/preferences/menu"),
2018 post(menu_order_update),
2019 )
2020 .route(
2021 &format!("{admin_path}/preferences/menu/moves"),
2022 post(menu_moves_update),
2023 )
2024 .route(
2025 &format!("{admin_path}/markdown/preview"),
2026 post(markdown_preview),
2027 )
2028 .route(&format!("{admin_path}/editor/menu"), get(editor_menu))
2029 .route(&format!("{admin_path}/palette"), get(palette::palette))
2030 .route(
2031 &format!("{admin_path}/preferences/sessions/revoke"),
2032 post(session_revoke),
2033 )
2034 .route(
2035 &format!("{admin_path}/preferences/sessions/revoke-others"),
2036 post(session_revoke_others),
2037 )
2038 .route(
2041 &format!("{admin_path}/pickers/{{source}}/search"),
2042 any(picker::search),
2043 )
2044 .route(
2045 &format!("{admin_path}/pickers/{{source}}/resolve"),
2046 any(picker::resolve),
2047 )
2048 .route(&format!("{admin_path}/logout"), post(logout));
2049
2050 for item in settings.iter().filter(|i| i.link.is_none()) {
2054 let path = format!("{admin_path}{}", item.route());
2055 let code = item.code.clone();
2056 let edit_code = code.clone();
2057 protected = protected.route(
2058 &path,
2059 get(
2060 move |State(state): State<AdminState>,
2061 Extension(shell): Extension<Shell>,
2062 Extension(user): Extension<AuthenticatedUser>| {
2063 let code = edit_code.clone();
2064 async move { settings_edit(state, shell, user, code).await }
2065 },
2066 )
2067 .post(
2068 move |State(state): State<AdminState>,
2069 Extension(shell): Extension<Shell>,
2070 Extension(user): Extension<AuthenticatedUser>,
2071 Extension(session): Extension<session::SessionHandle>,
2072 Form(data): Form<HashMap<String, String>>| {
2073 let code = code.clone();
2074 async move { settings_update(state, shell, user, session, code, data).await }
2075 },
2076 ),
2077 );
2078 }
2079
2080 let app = protected
2081 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
2082 .route(
2086 &format!("{admin_path}/login"),
2087 get(login_form).post(login_submit),
2088 )
2089 .route(
2090 &format!("{admin_path}/setup"),
2091 get(setup_form).post(setup_submit),
2092 )
2093 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
2094 .merge(mount_public(
2098 &app_public,
2099 &state.db,
2100 &state.auth,
2101 &state.field_types,
2102 &state.overrides,
2103 &state.signing_key,
2104 &state.storage_dir,
2105 &admin_path,
2106 &state.origin,
2107 &state.plugin_defined,
2108 state.auth.events(),
2109 &forms,
2110 ));
2111 let app = match app_public.iter().find(|r| r.is_site()) {
2114 Some(site) => {
2115 let ctx = routes::RouteCtx::new(
2116 state.db.clone(),
2117 state.auth.clone(),
2118 state.field_types.clone(),
2119 state.overrides.clone(),
2120 state.signing_key.clone(),
2121 &state.storage_dir,
2122 "/",
2123 &admin_path,
2124 &state.origin,
2125 state.plugin_defined.clone(),
2126 state.auth.events().clone(),
2127 forms.clone(),
2128 );
2129 app.fallback_service(site.route.mount(&ctx))
2130 }
2131 None => app.fallback(not_found_fallback),
2132 };
2133 let secure = state.secure_cookie;
2134 app
2135 .layer(middleware::from_fn_with_state(
2138 state.clone(),
2139 enforce_origin,
2140 ))
2141 .layer(middleware::from_fn_with_state(
2144 state.clone(),
2145 capture_client,
2146 ))
2147 .layer(
2152 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2153 header::CACHE_CONTROL,
2154 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
2155 ),
2156 )
2157 .layer(
2161 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2162 header::VARY,
2163 axum::http::HeaderValue::from_static("HX-Request"),
2164 ),
2165 )
2166 .layer(
2172 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2173 header::CONTENT_SECURITY_POLICY,
2174 axum::http::HeaderValue::from_static(CONTENT_SECURITY_POLICY),
2175 ),
2176 )
2177 .layer(
2178 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2179 header::X_CONTENT_TYPE_OPTIONS,
2180 axum::http::HeaderValue::from_static("nosniff"),
2181 ),
2182 )
2183 .layer(
2184 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2185 header::X_FRAME_OPTIONS,
2186 axum::http::HeaderValue::from_static("SAMEORIGIN"),
2187 ),
2188 )
2189 .layer(
2190 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2191 header::REFERRER_POLICY,
2192 axum::http::HeaderValue::from_static("same-origin"),
2193 ),
2194 )
2195 .layer(
2196 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2197 axum::http::HeaderName::from_static("permissions-policy"),
2198 axum::http::HeaderValue::from_static(
2199 "camera=(), microphone=(), geolocation=(), payment=()",
2200 ),
2201 ),
2202 )
2203 .layer(
2204 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2205 axum::http::HeaderName::from_static("cross-origin-opener-policy"),
2206 axum::http::HeaderValue::from_static("same-origin"),
2207 ),
2208 )
2209 .layer(
2212 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2213 header::STRICT_TRANSPORT_SECURITY,
2214 if secure {
2215 Some(axum::http::HeaderValue::from_static(
2216 "max-age=31536000; includeSubDomains",
2217 ))
2218 } else {
2219 None
2220 },
2221 ),
2222 )
2223 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
2224 handle_panic,
2225 ))
2226 .with_state(state)
2227}
2228
2229pub const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self'; connect-src 'self' https:; frame-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none'";
2233
2234async fn not_found_fallback(
2237 State(state): State<AdminState>,
2238 method: axum::http::Method,
2239 uri: axum::http::Uri,
2240) -> AdminError {
2241 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
2242 AdminError::NotFound
2243}
2244
2245async fn not_found_plain() -> AdminError {
2247 AdminError::NotFound
2248}
2249
2250fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
2252 let cause = err
2253 .downcast_ref::<&str>()
2254 .map(|s| s.to_string())
2255 .or_else(|| err.downcast_ref::<String>().cloned())
2256 .unwrap_or_else(|| "unknown panic".to_string());
2257 tracing::error!(panic = cause, "admin handler panicked");
2258 error::masked_500()
2259}
2260
2261pub fn normalize_path(path: &str) -> String {
2266 let trimmed = path.trim().trim_matches('/');
2267 if trimmed.is_empty() {
2268 "/admin".to_string()
2269 } else {
2270 format!("/{trimmed}")
2271 }
2272}
2273
2274#[allow(clippy::too_many_arguments)]
2278fn mount_public(
2279 regs: &[routes::PublicRouteReg],
2280 db: &Db,
2281 auth: &AuthService,
2282 field_types: &Arc<field::FieldRegistry>,
2283 overrides: &Arc<dyn field::OverrideResolver>,
2284 signing_key: &Arc<Vec<u8>>,
2285 storage_dir: &str,
2286 admin_path: &str,
2287 origin: &str,
2288 plugin_defined: &Arc<laterite_core::Registry>,
2289 events: &laterite_core::Events,
2290 forms: &form::Forms,
2291) -> Router<AdminState> {
2292 let mut router = Router::new();
2293 for reg in regs.iter().filter(|r| !r.is_site()) {
2295 let ctx = routes::RouteCtx::new(
2296 db.clone(),
2297 auth.clone(),
2298 field_types.clone(),
2299 overrides.clone(),
2300 signing_key.clone(),
2301 storage_dir,
2302 ®.path,
2303 admin_path,
2304 origin,
2305 plugin_defined.clone(),
2306 events.clone(),
2307 forms.clone(),
2308 );
2309 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
2314 }
2315 router
2316}
2317
2318pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
2321 let old = resource.base_path.clone();
2322 resource.base_path = base.to_string();
2323 if let Some(edit_base) = &mut resource.list.edit_base {
2324 if edit_base == &old {
2325 *edit_base = base.to_string();
2326 }
2327 }
2328 if let Some(form) = &mut resource.form {
2329 if form.base_path == old {
2330 form.base_path = base.to_string();
2331 }
2332 }
2333}
2334
2335fn prefix_resource(admin_path: &str, resource: &mut Resource) {
2336 resource.base_path = format!("{admin_path}{}", resource.base_path);
2337 if let Some(edit_base) = &mut resource.list.edit_base {
2338 *edit_base = format!("{admin_path}{edit_base}");
2339 }
2340 if let Some(form) = &mut resource.form {
2341 form.base_path = format!("{admin_path}{}", form.base_path);
2342 }
2343}
2344
2345pub(crate) struct AdminAsset {
2347 pub mime: &'static str,
2348 pub bytes: std::borrow::Cow<'static, [u8]>,
2352}
2353
2354pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2357
2358pub(crate) type AssetUrls = HashMap<&'static str, String>;
2362
2363pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2365 registry
2366 .iter()
2367 .map(|(&key, asset)| {
2368 (
2369 key,
2370 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2371 )
2372 })
2373 .collect()
2374}
2375
2376pub(crate) fn page_assets(
2383 keys: &[&str],
2384 base: &str,
2385 registry: &AssetRegistry,
2386 urls: &AssetUrls,
2387) -> Vec<PageAsset> {
2388 let mut seen = std::collections::HashSet::new();
2389 let mut out = Vec::new();
2390 for &key in keys {
2391 if !seen.insert(key) {
2392 continue;
2393 }
2394 match (registry.get(key), urls.get(key)) {
2395 (Some(asset), Some(path)) => out.push(PageAsset {
2396 url: format!("{base}/assets/{path}"),
2397 css: asset.mime.contains("css"),
2398 }),
2399 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2400 }
2401 }
2402 out
2403}
2404
2405pub const CODE_EDITOR_ASSETS: &[&str] = &[
2411 "vendor/codeeditor.js",
2412 "fields/codeeditor.js",
2413 "fields/codeeditor.css",
2414];
2415
2416pub const RICH_EDITOR_ASSETS: &[&str] = &[
2420 "vendor/richeditor.js",
2421 "vendor/richeditor.css",
2422 "fields/richeditor.js",
2423 "fields/richeditor.css",
2424];
2425
2426pub const MARKDOWN_ASSETS: &[&str] = &[
2430 "vendor/codeeditor.js",
2431 "fields/codeeditor.css",
2432 "fields/markdown.js",
2433 "fields/markdown.css",
2434];
2435
2436pub(crate) fn builtin_assets() -> AssetRegistry {
2447 let font = |bytes: &'static [u8]| AdminAsset {
2448 mime: "font/woff2",
2449 bytes: std::borrow::Cow::Borrowed(bytes),
2450 };
2451 HashMap::from([
2452 (
2453 "laterite.css",
2454 AdminAsset {
2455 mime: "text/css; charset=utf-8",
2456 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2457 },
2458 ),
2459 (
2460 "laterite.js",
2461 AdminAsset {
2462 mime: "text/javascript; charset=utf-8",
2463 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2464 },
2465 ),
2466 (
2467 "vendor/htmx.min.js",
2468 AdminAsset {
2469 mime: "text/javascript; charset=utf-8",
2470 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2471 },
2472 ),
2473 (
2474 "vendor/richeditor.js",
2475 AdminAsset {
2476 mime: "text/javascript; charset=utf-8",
2477 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/richeditor.js")),
2478 },
2479 ),
2480 (
2481 "vendor/richeditor.css",
2482 AdminAsset {
2483 mime: "text/css; charset=utf-8",
2484 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2485 "../assets/vendor/richeditor.css"
2486 )),
2487 },
2488 ),
2489 (
2490 "vendor/codeeditor.js",
2491 AdminAsset {
2492 mime: "text/javascript; charset=utf-8",
2493 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/codeeditor.js")),
2494 },
2495 ),
2496 (
2497 "fields/richeditor.js",
2498 AdminAsset {
2499 mime: "text/javascript; charset=utf-8",
2500 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/richeditor.js")),
2501 },
2502 ),
2503 (
2504 "fields/richeditor.css",
2505 AdminAsset {
2506 mime: "text/css; charset=utf-8",
2507 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2508 "../assets/fields/richeditor.css"
2509 )),
2510 },
2511 ),
2512 (
2513 "fields/markdown.js",
2514 AdminAsset {
2515 mime: "text/javascript; charset=utf-8",
2516 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/markdown.js")),
2517 },
2518 ),
2519 (
2520 "fields/markdown.css",
2521 AdminAsset {
2522 mime: "text/css; charset=utf-8",
2523 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/markdown.css")),
2524 },
2525 ),
2526 (
2527 "fields/codeeditor.js",
2528 AdminAsset {
2529 mime: "text/javascript; charset=utf-8",
2530 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/codeeditor.js")),
2531 },
2532 ),
2533 (
2534 "fields/codeeditor.css",
2535 AdminAsset {
2536 mime: "text/css; charset=utf-8",
2537 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2538 "../assets/fields/codeeditor.css"
2539 )),
2540 },
2541 ),
2542 (
2543 "fields/ref-picker.js",
2544 AdminAsset {
2545 mime: "text/javascript; charset=utf-8",
2546 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
2547 },
2548 ),
2549 (
2550 "fields/ref-picker.css",
2551 AdminAsset {
2552 mime: "text/css; charset=utf-8",
2553 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2554 "../assets/fields/ref-picker.css"
2555 )),
2556 },
2557 ),
2558 (
2559 "mark.svg",
2560 AdminAsset {
2561 mime: "image/svg+xml",
2562 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
2563 },
2564 ),
2565 (
2566 "mark.png",
2567 AdminAsset {
2568 mime: "image/png",
2569 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
2570 },
2571 ),
2572 (
2573 "fonts/space-grotesk-500.woff2",
2574 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
2575 ),
2576 (
2577 "fonts/space-grotesk-600.woff2",
2578 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
2579 ),
2580 (
2581 "fonts/space-grotesk-700.woff2",
2582 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
2583 ),
2584 (
2585 "fonts/ibm-plex-sans-400.woff2",
2586 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
2587 ),
2588 (
2589 "fonts/ibm-plex-sans-600.woff2",
2590 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
2591 ),
2592 (
2593 "fonts/ibm-plex-mono-400.woff2",
2594 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
2595 ),
2596 (
2597 "fonts/ibm-plex-mono-600.woff2",
2598 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
2599 ),
2600 ])
2601}
2602
2603async fn serve_asset(
2605 State(state): State<AdminState>,
2606 headers: HeaderMap,
2607 Path(path): Path<String>,
2608) -> Response {
2609 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
2613 let Some(asset) = state.assets.get(key.as_str()) else {
2614 return not_found();
2615 };
2616 let policy = if fingerprinted {
2617 http_cache::IMMUTABLE
2618 } else {
2619 http_cache::REVALIDATE
2620 };
2621 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
2622}
2623
2624fn mount_resource(
2629 resource: &Resource,
2630 field_types: &field::FieldRegistry,
2631 column_types: &list::ColumnRegistry,
2632 permission_codes: &std::collections::HashSet<String>,
2633 persisters: &persist::PersisterRegistry,
2634 listeners: &[laterite_core::ModelListenerReg],
2635) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2636 list::check(&resource.list, column_types, permission_codes)
2640 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2641 if resource.form.is_some() && resource.permission.is_none() {
2646 panic!(
2647 "admin resource `{}` has a create/edit form but no permission; a write \
2648 resource must declare a permission",
2649 resource.base_path
2650 );
2651 }
2652 let base = resource.base_path.clone();
2653 let list_path = base.clone();
2654 let mut list_cfg = resource.list.clone();
2655 if resource.list.columns.iter().any(|c| c.editable) {
2658 let Some(form_cfg) = resource.form.clone() else {
2659 panic!(
2660 "admin resource `{}` has an editable column but no form to edit through",
2661 resource.base_path
2662 );
2663 };
2664 for column in resource.list.columns.iter().filter(|c| c.editable) {
2665 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2666 panic!(
2667 "admin resource `{}`: column `{}` is editable but the form has no such field",
2668 resource.base_path, column.field
2669 );
2670 }
2671 }
2672 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2673 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2674 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2675 }
2676 let cell_cfg = list_cfg.clone();
2677 let mut router = Router::new().route(
2678 &base,
2679 get(
2680 move |State(state): State<AdminState>,
2681 Extension(shell): Extension<Shell>,
2682 Query(params): Query<list::ListParams>,
2683 Query(raw): Query<std::collections::HashMap<String, String>>,
2684 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2685 Extension(user): Extension<AuthenticatedUser>,
2686 Extension(session): Extension<session::SessionHandle>,
2687 headers: axum::http::HeaderMap| {
2688 let cfg = list_cfg.clone();
2689 let path = list_path.clone();
2690 async move {
2691 list::handle_remembering(
2692 &state,
2693 &cfg,
2694 &path,
2695 params,
2696 &raw,
2697 &user,
2698 shell,
2699 &headers,
2700 query_string.as_deref(),
2701 Some(&session),
2702 )
2703 .await
2704 }
2705 },
2706 ),
2707 );
2708
2709 if cell_cfg.editor.is_some() {
2710 let (cfg, list_path) = (cell_cfg, base.clone());
2711 router = router.route(
2712 &format!("{base}/{{id}}/cell"),
2713 post(
2714 move |State(state): State<AdminState>,
2715 Extension(shell): Extension<Shell>,
2716 Extension(user): Extension<AuthenticatedUser>,
2717 Extension(session): Extension<session::SessionHandle>,
2718 Path(id): Path<String>,
2719 headers: axum::http::HeaderMap,
2720 Form(data): Form<HashMap<String, String>>| {
2721 let (cfg, path) = (cfg.clone(), list_path.clone());
2722 async move {
2723 list::cell_handler(
2724 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2725 )
2726 .await
2727 }
2728 },
2729 ),
2730 );
2731 }
2732
2733 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2736 if resource.list.exportable {
2737 router = router.route(
2738 &format!("{base}/export"),
2739 get(
2740 move |state: State<AdminState>,
2741 Query(raw): Query<std::collections::HashMap<String, String>>,
2742 Extension(user): Extension<AuthenticatedUser>,
2743 Extension(shell): Extension<Shell>,
2744 Extension(session): Extension<session::SessionHandle>| {
2745 let cfg = export_cfg.clone();
2746 let path = export_path.clone();
2747 async move {
2748 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2749 }
2750 },
2751 ),
2752 );
2753 }
2754
2755 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2756 router = router.route(
2757 &format!("{base}/columns"),
2758 post(
2759 move |State(state): State<AdminState>,
2760 Extension(user): Extension<AuthenticatedUser>,
2761 Extension(session): Extension<session::SessionHandle>,
2762 headers: axum::http::HeaderMap,
2763 Form(pairs): Form<Vec<(String, String)>>| {
2764 let cfg = columns_cfg.clone();
2765 let path = columns_path.clone();
2766 async move {
2767 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2768 }
2769 },
2770 ),
2771 );
2772
2773 if resource.list.deletable {
2774 let persister: Arc<dyn persist::Persister> =
2778 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
2779 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
2780 panic!(
2781 "admin resource `{}`: unregistered persister `{key}`",
2782 resource.base_path
2783 )
2784 }),
2785 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
2786 };
2787 let ctx = Arc::new(bulk::BulkContext {
2788 base_path: resource.base_path.clone(),
2789 entity: resource.list.entity.clone(),
2790 persister,
2791 listeners: listeners
2792 .iter()
2793 .filter(|reg| reg.matches(&resource.list.entity))
2794 .map(|reg| reg.listener.clone())
2795 .collect(),
2796 });
2797 router =
2798 router.route(
2799 &format!("{base}/delete"),
2800 post(
2801 move |state: State<AdminState>,
2802 user: Extension<AuthenticatedUser>,
2803 session: Extension<session::SessionHandle>,
2804 headers: axum::http::HeaderMap,
2805 form: Form<Vec<(String, String)>>| {
2806 let ctx = ctx.clone();
2807 async move {
2808 bulk::delete_handler(state, user, session, ctx, headers, form).await
2809 }
2810 },
2811 ),
2812 );
2813 }
2814
2815 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
2818 let tree = resource.list.tree.clone().unwrap_or_default();
2819 let mut move_cfg = form::FormConfig::new(
2820 resource.list.entity.clone(),
2821 laterite_core::t!("Move to"),
2822 base.clone(),
2823 resource.list.id_field.clone(),
2824 vec![form::FormField::reference(
2825 &tree.parent_key,
2826 laterite_core::t!("New parent"),
2827 &source,
2828 )
2829 .help(laterite_core::t!("Leave empty for the top level."))],
2830 );
2831 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
2832 move_cfg = move_cfg.persist(persist);
2833 }
2834 move_cfg.done_path = Some(base.clone());
2835 let move_form = Arc::new(
2836 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
2837 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
2838 );
2839 let (cfg, list_path) = (resource.list.clone(), base.clone());
2840 let (form_get, form_post) = (move_form.clone(), move_form);
2841 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
2842 router = router.route(
2843 &format!("{base}/{{id}}/move"),
2844 get(
2845 move |State(state): State<AdminState>,
2846 Extension(shell): Extension<Shell>,
2847 Path(id): Path<String>| {
2848 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
2849 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
2850 },
2851 )
2852 .post(
2853 move |State(state): State<AdminState>,
2854 Extension(shell): Extension<Shell>,
2855 Extension(user): Extension<AuthenticatedUser>,
2856 Extension(session): Extension<session::SessionHandle>,
2857 Path(id): Path<String>,
2858 headers: axum::http::HeaderMap,
2859 Form(data): Form<HashMap<String, String>>| {
2860 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
2861 async move {
2862 list::move_record(
2863 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
2864 )
2865 .await
2866 }
2867 },
2868 ),
2869 );
2870 }
2871
2872 if resource.list.reorder.is_some() {
2873 let (cfg, list_path) = (resource.list.clone(), base.clone());
2874 router = router.route(
2875 &format!("{base}/reorder"),
2876 post(
2877 move |State(state): State<AdminState>,
2878 Extension(shell): Extension<Shell>,
2879 Extension(user): Extension<AuthenticatedUser>,
2880 Extension(session): Extension<session::SessionHandle>,
2881 headers: axum::http::HeaderMap,
2882 Form(pairs): Form<Vec<(String, String)>>| {
2883 let cfg = cfg.clone();
2884 let path = list_path.clone();
2885 async move {
2886 list::reorder_handler(
2887 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
2888 &headers, &pairs,
2889 )
2890 .await
2891 }
2892 },
2893 ),
2894 );
2895 }
2896
2897 if !resource.relations.is_empty() && resource.form.is_none() {
2900 panic!(
2901 "admin resource `{}` declares relations but has no form to show them on",
2902 resource.base_path
2903 );
2904 }
2905 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
2906 for rel in &resource.relations {
2907 let prepared = relation::PreparedRelation::prepare(
2908 resource,
2909 rel.clone(),
2910 field_types,
2911 column_types,
2912 permission_codes,
2913 persisters,
2914 listeners,
2915 )
2916 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2917 relations.push(Arc::new(prepared));
2918 }
2919 let relations = Arc::new(relations);
2920
2921 let mut prepared_form = None;
2922 if let Some(form_cfg) = resource.form.clone() {
2923 let mut prepared =
2926 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2927 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2928 prepared.stay = !relations.is_empty();
2930 let prepared = Arc::new(prepared);
2931 prepared_form = Some(prepared.clone());
2932 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
2933 let relations_for_new = relations.clone();
2934 router = router.route(
2935 &format!("{base}/new"),
2936 get(
2937 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
2938 let pf = new_pf.clone();
2939 let relations = relations_for_new.clone();
2940 async move {
2941 let pending = relations
2942 .iter()
2943 .map(|r| shell.tt(&r.config.title))
2944 .collect();
2945 form::new_form(&state, &pf, shell, pending)
2946 }
2947 },
2948 )
2949 .post(
2950 move |State(state): State<AdminState>,
2951 Extension(shell): Extension<Shell>,
2952 Extension(user): Extension<AuthenticatedUser>,
2953 Extension(session): Extension<session::SessionHandle>,
2954 headers: axum::http::HeaderMap,
2955 Form(data): Form<HashMap<String, String>>| {
2956 let pf = create_pf.clone();
2957 async move {
2958 form::create(&state, &pf, data, shell, &user, &session, &headers).await
2959 }
2960 },
2961 ),
2962 );
2963
2964 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
2965 let relations_for_edit = relations.clone();
2966 router = router.route(
2967 &format!("{base}/{{id}}/edit"),
2968 get(
2969 move |State(state): State<AdminState>,
2970 Extension(shell): Extension<Shell>,
2971 Extension(user): Extension<AuthenticatedUser>,
2972 Path(id): Path<String>| {
2973 let pf = edit_pf.clone();
2974 let relations = relations_for_edit.clone();
2975 async move {
2976 let mut sections = Vec::new();
2977 for rel in relations.iter() {
2978 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
2979 sections.push(section);
2980 }
2981 }
2982 form::edit_form(&state, &pf, id, shell, sections).await
2983 }
2984 },
2985 )
2986 .post(
2987 move |State(state): State<AdminState>,
2988 Extension(shell): Extension<Shell>,
2989 Extension(user): Extension<AuthenticatedUser>,
2990 Extension(session): Extension<session::SessionHandle>,
2991 Path(id): Path<String>,
2992 headers: axum::http::HeaderMap,
2993 Form(data): Form<HashMap<String, String>>| {
2994 let pf = update_pf.clone();
2995 async move {
2996 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
2997 }
2998 },
2999 ),
3000 );
3001 }
3002
3003 for rel in relations.iter() {
3004 let name = rel.config.name.clone();
3005 let mut routes: Router<AdminState> = Router::new();
3006 let r = rel.clone();
3007 routes =
3008 routes.route(
3009 &format!("{base}/{{id}}/{name}"),
3010 get(
3011 move |state: State<AdminState>,
3012 shell: Extension<Shell>,
3013 user: Extension<AuthenticatedUser>,
3014 id: Path<String>,
3015 params: Query<list::ListParams>,
3016 raw: Query<HashMap<String, String>>,
3017 headers: axum::http::HeaderMap| {
3018 let r = r.clone();
3019 async move {
3020 relation::index(state, shell, user, id, params, raw, headers, r).await
3021 }
3022 },
3023 ),
3024 );
3025 let (r_new, r_create) = (rel.clone(), rel.clone());
3026 routes = routes.route(
3027 &format!("{base}/{{id}}/{name}/new"),
3028 get(
3029 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
3030 let r = r_new.clone();
3031 async move { relation::new_form(state, shell, id, r).await }
3032 },
3033 )
3034 .post(
3035 move |state: State<AdminState>,
3036 shell: Extension<Shell>,
3037 user: Extension<AuthenticatedUser>,
3038 session: Extension<session::SessionHandle>,
3039 id: Path<String>,
3040 headers: axum::http::HeaderMap,
3041 data: Form<HashMap<String, String>>| {
3042 let r = r_create.clone();
3043 async move {
3044 relation::create(state, shell, user, session, id, headers, data, r).await
3045 }
3046 },
3047 ),
3048 );
3049 let (r_edit, r_update) = (rel.clone(), rel.clone());
3050 routes = routes.route(
3051 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
3052 get(
3053 move |state: State<AdminState>,
3054 shell: Extension<Shell>,
3055 ids: Path<(String, String)>| {
3056 let r = r_edit.clone();
3057 async move { relation::edit_form(state, shell, ids, r).await }
3058 },
3059 )
3060 .post(
3061 move |state: State<AdminState>,
3062 shell: Extension<Shell>,
3063 user: Extension<AuthenticatedUser>,
3064 session: Extension<session::SessionHandle>,
3065 ids: Path<(String, String)>,
3066 headers: axum::http::HeaderMap,
3067 data: Form<HashMap<String, String>>| {
3068 let r = r_update.clone();
3069 async move {
3070 relation::update(state, shell, user, session, ids, headers, data, r).await
3071 }
3072 },
3073 ),
3074 );
3075 let r_delete = rel.clone();
3076 routes = routes.route(
3077 &format!("{base}/{{id}}/{name}/delete"),
3078 post(
3079 move |state: State<AdminState>,
3080 shell: Extension<Shell>,
3081 user: Extension<AuthenticatedUser>,
3082 session: Extension<session::SessionHandle>,
3083 id: Path<String>,
3084 headers: axum::http::HeaderMap,
3085 pairs: Form<Vec<(String, String)>>| {
3086 let r = r_delete.clone();
3087 async move {
3088 relation::delete(state, shell, user, session, id, headers, pairs, r).await
3089 }
3090 },
3091 ),
3092 );
3093 if rel.reorderable() {
3094 let r_reorder = rel.clone();
3095 routes = routes.route(
3096 &format!("{base}/{{id}}/{name}/reorder"),
3097 post(
3098 move |state: State<AdminState>,
3099 shell: Extension<Shell>,
3100 user: Extension<AuthenticatedUser>,
3101 session: Extension<session::SessionHandle>,
3102 id: Path<String>,
3103 headers: axum::http::HeaderMap,
3104 pairs: Form<Vec<(String, String)>>| {
3105 let r = r_reorder.clone();
3106 async move {
3107 relation::reorder(state, shell, user, session, id, headers, pairs, r)
3108 .await
3109 }
3110 },
3111 ),
3112 );
3113 }
3114 if let Some(permission) = &rel.config.permission {
3115 routes = guard_with_permission(routes, permission);
3116 }
3117 router = router.merge(routes);
3118 }
3119
3120 if let Some(permission) = &resource.permission {
3122 router = guard_with_permission(router, permission);
3123 }
3124 (router, prepared_form)
3125}
3126
3127fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
3132 let needed: Arc<str> = Arc::from(permission);
3133 router.route_layer(middleware::from_fn(
3134 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
3135 let needed = needed.clone();
3136 async move {
3137 if user.allows(&needed) {
3138 next.run(req).await
3139 } else {
3140 forbidden()
3141 }
3142 }
3143 },
3144 ))
3145}
3146
3147async fn require_auth(
3153 State(state): State<AdminState>,
3154 jar: CookieJar,
3155 request: Request,
3156 next: Next,
3157) -> Response {
3158 let login = format!("{}/login", state.admin_path);
3159 let client_ctx = request
3160 .extensions()
3161 .get::<RequestContext>()
3162 .cloned()
3163 .unwrap_or_default();
3164 let mut ended: Option<laterite_auth::SessionEnd> = None;
3169 let live = match jar.get(SESSION_COOKIE) {
3170 Some(cookie) => {
3171 let token = cookie.value().to_string();
3172 match state.auth.resolve_session(&token).await {
3173 Ok(resolved) => Some((token, resolved)),
3174 Err(_) => {
3175 ended = state.auth.session_end(&token).await.ok().flatten();
3176 None
3177 }
3178 }
3179 }
3180 None => None,
3181 };
3182 let htmx = request.headers().contains_key("hx-request");
3183 let (token, resolved, recalled) = match live {
3184 Some((token, resolved)) => (token, resolved, None),
3185 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
3188 return signed_out(&state, jar, &login, ended, htmx);
3189 }
3190 None => match recall(&state, &jar, &client_ctx).await {
3191 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
3192 None => {
3193 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3197 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
3198 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
3199 }
3200 }
3201 return signed_out(&state, jar, &login, ended, htmx);
3202 }
3203 },
3204 };
3205 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
3206
3207 let mut request = request;
3212 let safe = session::is_safe_method(request.method());
3213 let mut deferred: Option<session::CsrfPending> = None;
3215 if !safe {
3216 let (parts, body) = request.into_parts();
3217 let (body, submitted) = if session::is_multipart(&parts.headers) {
3224 let token = session::header_token(&parts.headers)
3225 .or_else(|| session::query_token(parts.uri.query()));
3226 if token.is_none() {
3231 deferred = Some(session::CsrfPending::default());
3232 }
3233 (body, token)
3234 } else {
3235 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
3236 .await
3237 .unwrap_or_default();
3238 let token = session::submitted_token(&parts.headers, &bytes);
3239 (Body::from(bytes), token)
3240 };
3241 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
3242 {
3243 tracing::warn!(
3244 user_id = resolved.identity.user.id,
3245 "admin CSRF check failed"
3246 );
3247 return error::csrf_rejected();
3248 }
3249 request = Request::from_parts(parts, body);
3250 }
3251
3252 let user = resolved.identity;
3253 let acting_user_id = user.user.id;
3255 let path = request.uri().path().to_string();
3256 let preferences = format!("{}/preferences", state.admin_path);
3259 if !path.starts_with(&preferences)
3260 && path != format!("{}/logout", state.admin_path)
3261 && state
3262 .auth
3263 .must_change_password(user.user.id)
3264 .await
3265 .unwrap_or(false)
3266 {
3267 let to = format!("{preferences}#password");
3268 let response = if htmx {
3269 (
3270 [("HX-Redirect", to.as_str())],
3271 axum::http::StatusCode::NO_CONTENT,
3272 )
3273 .into_response()
3274 } else {
3275 Redirect::to(&to).into_response()
3276 };
3277 return with_recall_cookies(response, recalled, jar, token, &state);
3278 }
3279 let flash = if safe {
3282 handle.take_flash()
3283 } else {
3284 Vec::new()
3285 };
3286 let accept_language = request
3290 .headers()
3291 .get("accept-language")
3292 .and_then(|v| v.to_str().ok());
3293 let serveable = state.catalogs.locales();
3294 let chain = resolve_locale_chain(
3295 user.user.locale.as_deref(),
3296 accept_language,
3297 &state.default_locale,
3298 &serveable,
3299 );
3300 let i18n = Translator::with_chain(chain, state.catalogs.clone());
3301 let translator = i18n.clone();
3304 let own = OwnMenu::load(&state, user.user.id).await;
3306 let (own_nav, own_settings) = own_tree(&state, &own);
3307 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
3308 &own_nav,
3309 &own_settings,
3310 &state.admin_path,
3311 &user.permissions,
3312 own.nav_order.as_deref(),
3313 &path,
3314 &i18n,
3315 icons::Icons::new(&state.icons, &state.sprite_url),
3316 &state.order,
3317 own.settings_order.as_deref(),
3318 );
3319 let brand = state.brand().await;
3320 let shell = Shell::new(
3321 &state.admin_path,
3322 brand,
3323 &visible_nav,
3324 &user,
3325 state.timezone,
3326 sidebar,
3327 active_nav.as_deref(),
3328 handle.csrf_token(),
3329 flash,
3330 i18n,
3331 state.asset_urls.clone(),
3332 state.origin.to_string(),
3333 state.icons.clone(),
3334 state.sprite_url.clone(),
3335 );
3336 request.extensions_mut().insert(user);
3337 request.extensions_mut().insert(shell);
3338 request.extensions_mut().insert(handle.clone());
3339 if let Some(pending) = &deferred {
3341 request.extensions_mut().insert(pending.clone());
3342 }
3343 let mut response = next.run(request).await;
3344
3345 if let Some(pending) = &deferred {
3350 if !pending.was_satisfied() {
3351 tracing::error!(
3352 user_id = acting_user_id,
3353 "a multipart route returned without verifying its request token"
3354 );
3355 return error::csrf_rejected();
3356 }
3357 }
3358
3359 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
3363 response = error::localized_error(meta.kind, &translator);
3364 }
3365
3366 if let Some(blob) = handle.dirty_blob() {
3369 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
3370 tracing::error!(error = %e, "persisting admin session failed");
3371 }
3372 }
3373 with_recall_cookies(response, recalled, jar, token, &state)
3374}
3375
3376fn with_recall_cookies(
3380 response: Response,
3381 recalled: Option<laterite_auth::RememberCredential>,
3382 jar: CookieJar,
3383 token: String,
3384 state: &AdminState,
3385) -> Response {
3386 match recalled {
3387 Some(credential) => {
3388 let jar = jar
3389 .add(session_cookie(
3390 token,
3391 &state.cookie_path,
3392 state.secure_cookie,
3393 ))
3394 .add(remember_cookie(
3395 credential,
3396 &state.cookie_path,
3397 state.secure_cookie,
3398 ));
3399 (jar, response).into_response()
3400 }
3401 None => response,
3402 }
3403}
3404
3405async fn recall(
3409 state: &AdminState,
3410 jar: &CookieJar,
3411 ctx: &RequestContext,
3412) -> Option<(
3413 String,
3414 laterite_auth::ResolvedSession,
3415 laterite_auth::RememberCredential,
3416)> {
3417 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
3418 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
3419 let token = recalled.session.token.clone();
3420 let resolved = state.auth.resolve_session(&token).await.ok()?;
3421 Some((token, resolved, recalled.remember))
3422}
3423
3424const MAX_FORM_BYTES: usize = 1024 * 1024;
3427
3428async fn capture_client(
3436 State(state): State<AdminState>,
3437 mut request: Request,
3438 next: Next,
3439) -> Response {
3440 let peer = request
3441 .extensions()
3442 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3443 .map(|info| info.0);
3444 let headers = request.headers();
3445 let ctx = RequestContext {
3446 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3447 user_agent: clientip::user_agent(headers),
3448 };
3449 request.extensions_mut().insert(ctx);
3450 next.run(request).await
3451}
3452
3453async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3454 if !session::is_safe_method(request.method())
3455 && !session::origin_ok(request.headers(), &state.origin)
3456 {
3457 tracing::warn!(
3458 path = request.uri().path(),
3459 "admin CSRF origin check failed"
3460 );
3461 return error::csrf_rejected();
3462 }
3463 next.run(request).await
3464}
3465
3466#[derive(Deserialize)]
3467struct LoginQuery {
3468 ended: Option<String>,
3469}
3470
3471async fn login_form(
3472 State(state): State<AdminState>,
3473 headers: HeaderMap,
3474 Query(query): Query<LoginQuery>,
3475) -> Response {
3476 match state.auth.has_any_operator().await {
3478 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
3479 Ok(true) => {
3480 let i18n = pre_auth_translator(&state, &headers);
3481 let notice = query
3482 .ended
3483 .as_deref()
3484 .and_then(end_notice)
3485 .map(|text| i18n.t(&text));
3486 render(LoginTemplate {
3487 base: state.admin_path.to_string(),
3488 asset_urls: state.asset_urls.clone(),
3489 brand: state.brand().await,
3490 error: None,
3491 notice,
3492 i18n,
3493 })
3494 }
3495 Err(_) => render_error(),
3496 }
3497}
3498
3499#[derive(Deserialize)]
3500struct LoginForm {
3501 username: String,
3502 password: String,
3503 #[serde(default)]
3505 remember: Option<String>,
3506}
3507
3508async fn login_submit(
3509 State(state): State<AdminState>,
3510 jar: CookieJar,
3511 headers: HeaderMap,
3512 Extension(ctx): Extension<RequestContext>,
3513 Form(form): Form<LoginForm>,
3514) -> Response {
3515 match state
3516 .auth
3517 .authenticate(&form.username, &form.password, &ctx)
3518 .await
3519 {
3520 Ok(session) => {
3521 let wants_remember = form.remember.is_some();
3522 let user_id = session.user_id;
3523 let mut jar = jar.add(session_cookie(
3524 session.token,
3525 &state.cookie_path,
3526 state.secure_cookie,
3527 ));
3528 if wants_remember {
3529 match state.auth.issue_remember(user_id).await {
3530 Ok(credential) => {
3531 jar = jar.add(remember_cookie(
3532 credential,
3533 &state.cookie_path,
3534 state.secure_cookie,
3535 ));
3536 }
3537 Err(e) => {
3539 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
3540 }
3541 }
3542 }
3543 (jar, Redirect::to(&state.admin_path)).into_response()
3544 }
3545 Err(_) => {
3546 let i18n = pre_auth_translator(&state, &headers);
3547 render(LoginTemplate {
3548 base: state.admin_path.to_string(),
3549 asset_urls: state.asset_urls.clone(),
3550 brand: state.brand().await,
3551 error: Some(i18n.t(&t!("Invalid username or password."))),
3552 notice: None,
3553 i18n,
3554 })
3555 }
3556 }
3557}
3558
3559fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
3569 Cookie::build((SESSION_COOKIE, token))
3570 .path(admin_path.to_string())
3571 .http_only(true)
3572 .secure(secure)
3573 .same_site(SameSite::Lax)
3574 .build()
3575}
3576
3577fn remember_cookie(
3580 credential: laterite_auth::RememberCredential,
3581 admin_path: &str,
3582 secure: bool,
3583) -> Cookie<'static> {
3584 let seconds = (credential.expires_at - chrono::Utc::now())
3585 .num_seconds()
3586 .max(0);
3587 Cookie::build((REMEMBER_COOKIE, credential.cookie))
3588 .path(admin_path.to_string())
3589 .http_only(true)
3590 .secure(secure)
3591 .same_site(SameSite::Lax)
3592 .max_age(cookie::time::Duration::seconds(seconds))
3593 .build()
3594}
3595
3596fn signed_out(
3603 state: &AdminState,
3604 jar: CookieJar,
3605 login: &str,
3606 ended: Option<laterite_auth::SessionEnd>,
3607 htmx: bool,
3608) -> Response {
3609 let to = match ended.map(laterite_auth::SessionEnd::code) {
3610 Some(code) => format!("{login}?ended={code}"),
3611 None => login.to_string(),
3612 };
3613 let jar = jar
3614 .remove(
3615 Cookie::build((SESSION_COOKIE, ""))
3616 .path(state.cookie_path.to_string())
3617 .build(),
3618 )
3619 .remove(remember_removal(&state.cookie_path));
3620 if htmx {
3621 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
3622 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
3623 response.headers_mut().insert("hx-redirect", value);
3624 }
3625 return response;
3626 }
3627 (jar, Redirect::to(&to)).into_response()
3628}
3629
3630fn end_notice(code: &str) -> Option<Text> {
3633 Some(match code {
3634 "password_changed" => {
3635 t!("Your password was changed, so you were signed out. Sign in with the new one.")
3636 }
3637 "deactivated" => t!("This account was deactivated."),
3638 "signed_out_elsewhere" => t!("You were signed out from another device."),
3639 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3640 _ => return None,
3641 })
3642}
3643
3644fn remember_removal(admin_path: &str) -> Cookie<'static> {
3646 Cookie::build((REMEMBER_COOKIE, ""))
3647 .path(admin_path.to_string())
3648 .build()
3649}
3650
3651#[derive(Deserialize)]
3652struct SetupForm {
3653 username: String,
3654 first_name: String,
3655 last_name: String,
3656 email: String,
3657 password: String,
3658 timezone: String,
3659}
3660
3661async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3664 match state.auth.has_any_operator().await {
3665 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3666 Ok(false) => render(setup_view(
3667 &state.admin_path,
3668 state.brand().await,
3669 state.timezone,
3670 None,
3671 pre_auth_translator(&state, &headers),
3672 state.asset_urls.clone(),
3673 state.auth.password_policy().min_length,
3674 )),
3675 Err(_) => render_error(),
3676 }
3677}
3678
3679async fn setup_submit(
3680 State(state): State<AdminState>,
3681 jar: CookieJar,
3682 headers: HeaderMap,
3683 client: Option<Extension<RequestContext>>,
3684 Form(form): Form<SetupForm>,
3685) -> Response {
3686 match state.auth.has_any_operator().await {
3688 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3689 Ok(false) => {}
3690 Err(_) => return render_error(),
3691 }
3692
3693 let username = form.username.trim();
3694 let email = form.email.trim();
3695 let first_name = form.first_name.trim();
3696 let last_name = form.last_name.trim();
3697 let tz = form.timezone.trim();
3698 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3699 {
3700 return render(setup_view(
3701 &state.admin_path,
3702 state.brand().await,
3703 state.timezone,
3704 Some(t!(
3705 "Username, first name, email, and password are all required."
3706 )),
3707 pre_auth_translator(&state, &headers),
3708 state.asset_urls.clone(),
3709 state.auth.password_policy().min_length,
3710 ));
3711 }
3712 if tz.parse::<Tz>().is_err() {
3714 return render(setup_view(
3715 &state.admin_path,
3716 state.brand().await,
3717 state.timezone,
3718 Some(t!("That is not a recognised timezone.")),
3719 pre_auth_translator(&state, &headers),
3720 state.asset_urls.clone(),
3721 state.auth.password_policy().min_length,
3722 ));
3723 }
3724
3725 let min_length = state.auth.password_policy().min_length;
3726 if form.password.chars().count() < min_length {
3727 return render(setup_view(
3728 &state.admin_path,
3729 state.brand().await,
3730 state.timezone,
3731 Some(t!(
3732 "A password needs at least {n} characters.",
3733 n = min_length as i64
3734 )),
3735 pre_auth_translator(&state, &headers),
3736 state.asset_urls.clone(),
3737 state.auth.password_policy().min_length,
3738 ));
3739 }
3740 let new = NewOperator {
3741 username,
3742 email,
3743 first_name,
3744 last_name: (!last_name.is_empty()).then_some(last_name),
3745 password: &form.password,
3746 timezone: Some(tz),
3747 };
3748 let id = match state.auth.create_superuser(new).await {
3749 Ok(id) => id,
3750 Err(_) => {
3751 return render(setup_view(
3752 &state.admin_path,
3753 state.brand().await,
3754 state.timezone,
3755 Some(t!(
3756 "Could not create the account. The username or email may already be taken."
3757 )),
3758 pre_auth_translator(&state, &headers),
3759 state.asset_urls.clone(),
3760 state.auth.password_policy().min_length,
3761 ));
3762 }
3763 };
3764 let actor = laterite_core::Actor::system("first-run setup");
3767 let target = id.to_string();
3768 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
3769 "backend_user",
3770 &target,
3771 Some(username),
3772 );
3773 if let Err(e) = state.auth.record_audit(entry).await {
3774 tracing::error!(error = %e, "failed to write audit log entry");
3775 }
3776
3777 match state
3779 .auth
3780 .authenticate(
3781 username,
3782 &form.password,
3783 &client.map(|c| c.0).unwrap_or_default(),
3784 )
3785 .await
3786 {
3787 Ok(session) => {
3788 let cookie = session_cookie(session.token, &state.cookie_path, state.secure_cookie);
3789 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
3790 }
3791 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3792 }
3793}
3794
3795fn setup_view(
3798 admin_path: &str,
3799 brand: String,
3800 default_tz: Tz,
3801 error: Option<Text>,
3802 i18n: Translator,
3803 asset_urls: Arc<AssetUrls>,
3804 min_password: usize,
3805) -> SetupTemplate {
3806 let default_name = default_tz.name();
3807 let zones = TZ_VARIANTS
3808 .iter()
3809 .filter(|tz| zone_offered(tz.name()))
3810 .map(|tz| TzOption {
3811 name: tz.name().to_string(),
3812 selected: tz.name() == default_name,
3813 })
3814 .collect();
3815 SetupTemplate {
3816 base: admin_path.to_string(),
3817 asset_urls,
3818 brand,
3819 zones,
3820 error: error.map(|e| i18n.t(&e)),
3821 min_password: min_password as i64,
3822 i18n,
3823 }
3824}
3825
3826async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
3827 if let Some(cookie) = jar.get(SESSION_COOKIE) {
3828 let _ = state.auth.logout(cookie.value()).await;
3829 }
3830 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3833 let _ = state.auth.revoke_remember(cookie.value()).await;
3834 }
3835 let removal = Cookie::build((SESSION_COOKIE, ""))
3836 .path(state.cookie_path.to_string())
3837 .build();
3838 (
3839 jar.remove(removal)
3840 .remove(remember_removal(&state.cookie_path)),
3841 Redirect::to(&format!("{}/login", state.admin_path)),
3842 )
3843 .into_response()
3844}
3845
3846async fn dashboard(
3847 Extension(shell): Extension<Shell>,
3848 Extension(user): Extension<AuthenticatedUser>,
3849) -> Response {
3850 render(DashboardTemplate {
3851 username: user.user.username,
3852 shell,
3853 })
3854}
3855
3856fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
3859 match &item.permission {
3860 None => true,
3861 Some(p) => perms.allows(p),
3862 }
3863}
3864
3865fn visible_settings(
3869 items: &[settings::SettingsItem],
3870 perms: &PermissionSet,
3871) -> Vec<settings::SettingsItem> {
3872 items
3873 .iter()
3874 .filter(|item| operator_can_see(item, perms))
3875 .cloned()
3876 .collect()
3877}
3878
3879async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
3880 settings::index(shell)
3881}
3882
3883async fn settings_edit(
3884 state: AdminState,
3885 shell: Shell,
3886 user: AuthenticatedUser,
3887 code: String,
3888) -> Response {
3889 let items = visible_settings(&state.settings, &user.permissions);
3892 match items
3893 .iter()
3894 .find(|item| item.code == code && item.link.is_none())
3895 {
3896 Some(item) => settings::edit_form(&state, item, shell, &user).await,
3897 None => not_found(),
3898 }
3899}
3900
3901async fn settings_update(
3902 state: AdminState,
3903 shell: Shell,
3904 user: AuthenticatedUser,
3905 session: session::SessionHandle,
3906 code: String,
3907 data: HashMap<String, String>,
3908) -> Response {
3909 let items = visible_settings(&state.settings, &user.permissions);
3910 match items
3911 .iter()
3912 .find(|item| item.code == code && item.link.is_none())
3913 {
3914 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
3915 None => not_found(),
3916 }
3917}
3918
3919async fn preferences_form(
3921 State(state): State<AdminState>,
3922 Extension(shell): Extension<Shell>,
3923 Extension(user): Extension<AuthenticatedUser>,
3924 jar: CookieJar,
3925) -> Response {
3926 let account = account_security(&state, &user, &shell, &jar).await;
3927 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
3928 .into_iter()
3929 .filter(|i| i.per_operator)
3930 .map(|i| PreferenceLink {
3931 label: shell.tt(&i.label),
3932 description: shell.tt(&i.description),
3933 href: i.path(&state.admin_path),
3934 })
3935 .collect();
3936 let own = OwnMenu::load(&state, user.user.id).await;
3937 let chosen = |key: &str| -> String {
3938 own.moves
3939 .get(key)
3940 .map(|m| {
3941 if m.hierarchy == nav::TOP_HIERARCHY {
3942 "nav".to_string()
3943 } else {
3944 "settings".to_string()
3945 }
3946 })
3947 .unwrap_or_default()
3948 };
3949 let mut menu_entries: Vec<MenuEntryView> = state
3950 .nav_items
3951 .iter()
3952 .filter(|i| i.path.is_some() && !matches!(i.key.as_str(), "dashboard" | "settings"))
3953 .filter(|i| {
3954 i.permission
3955 .as_deref()
3956 .is_none_or(|p| user.permissions.allows(p))
3957 })
3958 .map(|i| MenuEntryView {
3959 key: i.key.clone(),
3960 label: shell.tt(&i.label),
3961 home: "nav",
3962 chosen: chosen(&i.key),
3963 })
3964 .collect();
3965 menu_entries.extend(
3966 visible_settings(&state.settings, &user.permissions)
3967 .into_iter()
3968 .filter(|i| !i.per_operator)
3969 .map(|i| MenuEntryView {
3970 chosen: chosen(&i.code),
3971 key: i.code,
3972 label: shell.tt(&i.label),
3973 home: "settings",
3974 }),
3975 );
3976 render(preferences_view(
3977 &shell,
3978 &user,
3979 state.timezone,
3980 &state.default_locale,
3981 &offered_locales(&state.catalogs),
3982 account,
3983 state.auth.password_policy().min_length,
3984 items,
3985 menu_entries,
3986 ))
3987}
3988
3989struct PreferenceLink {
3991 label: String,
3992 description: String,
3993 href: String,
3994}
3995
3996#[derive(Deserialize)]
3997struct RevokeSessionForm {
3998 id: String,
3999}
4000
4001async fn session_revoke(
4003 State(state): State<AdminState>,
4004 Extension(user): Extension<AuthenticatedUser>,
4005 Extension(session): Extension<session::SessionHandle>,
4006 Form(form): Form<RevokeSessionForm>,
4007) -> Response {
4008 match state.auth.revoke_session(user.user.id, &form.id).await {
4009 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
4010 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
4011 Err(e) => {
4012 tracing::error!(error = %e, "revoking a session failed");
4013 session.push_flash(
4014 FlashLevel::Error,
4015 t!("That session could not be signed out."),
4016 );
4017 }
4018 }
4019 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4020}
4021
4022#[derive(Deserialize)]
4024struct MenuOrderForm {
4025 #[serde(default)]
4026 order: String,
4027 #[serde(default)]
4029 hierarchy: String,
4030}
4031
4032async fn menu_moves_update(
4036 State(state): State<AdminState>,
4037 Extension(user): Extension<AuthenticatedUser>,
4038 Extension(session): Extension<session::SessionHandle>,
4039 Form(form): Form<HashMap<String, String>>,
4040) -> Response {
4041 let mut moves = serde_json::Map::new();
4042 for (field, place) in &form {
4043 let Some(key) = field.strip_prefix("place:") else {
4044 continue;
4045 };
4046 if key.is_empty() || key.len() > 200 || !matches!(place.as_str(), "nav" | "settings") {
4047 continue;
4048 }
4049 moves.insert(key.to_string(), serde_json::Value::String(place.clone()));
4050 }
4051 let result = if moves.is_empty() {
4052 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::MOVES_PREFERENCE)
4053 .await
4054 } else {
4055 laterite_auth::store::set_user_preference(
4056 &state.db,
4057 user.user.id,
4058 nav::MOVES_PREFERENCE,
4059 &serde_json::Value::Object(moves).to_string(),
4060 )
4061 .await
4062 };
4063 if let Err(e) = result {
4064 tracing::error!(error = %e, "storing the menu moves failed");
4065 return render_error();
4066 }
4067 session.push_flash(session::FlashLevel::Success, t!("Menu saved."));
4068 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4069}
4070
4071async fn markdown_preview(
4074 Extension(_user): Extension<AuthenticatedUser>,
4075 Form(form): Form<HashMap<String, String>>,
4076) -> Response {
4077 let text = form.get("text").map(String::as_str).unwrap_or_default();
4078 if text.len() > 1 << 20 {
4079 return (axum::http::StatusCode::PAYLOAD_TOO_LARGE, "text over 1 MiB").into_response();
4080 }
4081 Html(laterite_core::markdown::render(text)).into_response()
4082}
4083
4084async fn editor_menu(
4087 State(state): State<AdminState>,
4088 Extension(shell): Extension<Shell>,
4089 Extension(_user): Extension<AuthenticatedUser>,
4090 Query(query): Query<HashMap<String, String>>,
4091) -> Response {
4092 let Some(surface) = query.get("surface").and_then(|k| editor::Surface::parse(k)) else {
4093 return (axum::http::StatusCode::BAD_REQUEST, "unknown surface").into_response();
4094 };
4095 let registry = &state.plugin_defined;
4096 let mut menu = editor::Menu::default();
4097 for cmd in editor::commands(
4098 registry.items::<editor::EditorCommand>().into_iter(),
4099 surface,
4100 ) {
4101 let (html, text) = match &cmd.insert {
4102 editor::Insert::Html(h) => (Some(h.clone()), None),
4103 editor::Insert::Text(t) => (None, Some(t.clone())),
4104 };
4105 menu.commands.push(editor::MenuCommand {
4106 key: cmd.key.clone(),
4107 label: shell.tt(&cmd.label),
4108 html,
4109 text,
4110 });
4111 }
4112 for source in registry.items::<editor::SnippetSourceReg>() {
4113 menu.snippets.extend(source.0.list(&state.db).await);
4114 }
4115 menu.snippets.sort_by(|a, b| a.name.cmp(&b.name));
4116 axum::Json(menu).into_response()
4117}
4118
4119async fn menu_order_update(
4124 State(state): State<AdminState>,
4125 Extension(user): Extension<AuthenticatedUser>,
4126 Extension(session): Extension<session::SessionHandle>,
4127 headers: axum::http::HeaderMap,
4128 Form(form): Form<MenuOrderForm>,
4129) -> Response {
4130 let order: Vec<&str> = form
4131 .order
4132 .split(',')
4133 .map(str::trim)
4134 .filter(|k| !k.is_empty() && k.len() <= 200)
4135 .take(100)
4136 .collect();
4137 let hierarchy = if form.hierarchy.is_empty() {
4138 nav::TOP_HIERARCHY
4139 } else {
4140 form.hierarchy.as_str()
4141 };
4142 if !nav::OPERATOR_HIERARCHIES.contains(&hierarchy) {
4143 return AdminError::NotFound.into_response();
4144 }
4145 let key = nav::order_preference(hierarchy);
4146 let result = if order.is_empty() {
4147 let mut cleared = Ok(());
4149 for key in nav::OPERATOR_HIERARCHIES
4150 .iter()
4151 .map(|h| nav::order_preference(h))
4152 .chain(std::iter::once(nav::MOVES_PREFERENCE.to_string()))
4153 {
4154 if let Err(e) =
4155 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
4156 {
4157 cleared = Err(e);
4158 }
4159 }
4160 cleared
4161 } else {
4162 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &order.join(","))
4163 .await
4164 };
4165 if let Err(e) = result {
4166 tracing::error!(error = %e, "storing the menu order failed");
4167 return render_error();
4168 }
4169 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
4170 return axum::http::StatusCode::NO_CONTENT.into_response();
4171 }
4172 session.push_flash(
4173 FlashLevel::Success,
4174 if order.is_empty() {
4175 t!("The menu is back in its default order.")
4176 } else {
4177 t!("Saved.")
4178 },
4179 );
4180 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4181}
4182
4183async fn session_revoke_others(
4184 State(state): State<AdminState>,
4185 Extension(user): Extension<AuthenticatedUser>,
4186 Extension(session): Extension<session::SessionHandle>,
4187 jar: CookieJar,
4188) -> Response {
4189 let token = jar
4190 .get(SESSION_COOKIE)
4191 .map(|c| c.value().to_string())
4192 .unwrap_or_default();
4193 match state.auth.sign_out_everywhere(user.user.id, &token).await {
4194 Ok(_) => session.push_flash(
4195 FlashLevel::Success,
4196 t!("Every other session was signed out."),
4197 ),
4198 Err(e) => {
4199 tracing::error!(error = %e, "signing out other sessions failed");
4200 session.push_flash(
4201 FlashLevel::Error,
4202 t!("Those sessions could not be signed out."),
4203 );
4204 }
4205 }
4206 (
4209 jar.remove(remember_removal(&state.cookie_path)),
4210 Redirect::to(&format!("{}/preferences", state.admin_path)),
4211 )
4212 .into_response()
4213}
4214
4215#[derive(Deserialize)]
4216struct PasswordForm {
4217 current_password: String,
4218 new_password: String,
4219 confirm_password: String,
4220}
4221
4222async fn password_update(
4227 State(state): State<AdminState>,
4228 Extension(user): Extension<AuthenticatedUser>,
4229 Extension(session): Extension<session::SessionHandle>,
4230 Extension(ctx): Extension<RequestContext>,
4231 jar: CookieJar,
4232 Form(form): Form<PasswordForm>,
4233) -> Response {
4234 let back = format!("{}/preferences#password", state.admin_path);
4235 let refuse = |message: Text| {
4236 session.push_flash(FlashLevel::Error, message);
4237 Redirect::to(&back).into_response()
4238 };
4239 if form.new_password != form.confirm_password {
4240 return refuse(t!("The new passwords do not match."));
4241 }
4242 let min_length = state.auth.password_policy().min_length;
4243 if form.new_password.chars().count() < min_length {
4244 return refuse(t!(
4245 "A password needs at least {n} characters.",
4246 n = min_length as i64
4247 ));
4248 }
4249 let remembered_here = match jar.get(REMEMBER_COOKIE) {
4252 Some(cookie) => {
4253 state
4254 .auth
4255 .remember_holder(cookie.value())
4256 .await
4257 .ok()
4258 .flatten()
4259 == Some(user.user.id)
4260 }
4261 None => false,
4262 };
4263 let token = jar
4264 .get(SESSION_COOKIE)
4265 .map(|c| c.value().to_string())
4266 .unwrap_or_default();
4267 let changed = state
4268 .auth
4269 .change_own_password(
4270 user.user.id,
4271 &form.current_password,
4272 &form.new_password,
4273 &token,
4274 &ctx,
4275 )
4276 .await;
4277 match changed {
4278 Ok(()) => {}
4279 Err(laterite_auth::AuthError::InvalidCredentials) => {
4280 return refuse(t!("Your current password is not correct."));
4281 }
4282 Err(laterite_auth::AuthError::TooManyAttempts) => {
4283 return refuse(t!("Too many attempts. Try again later."));
4284 }
4285 Err(e) => {
4286 tracing::error!(error = %e, "changing a password failed");
4287 return refuse(t!("The password could not be changed."));
4288 }
4289 }
4290 session.push_flash_sticky(
4291 FlashLevel::Success,
4292 t!("Password changed. Every other device was signed out."),
4293 );
4294 let jar = if remembered_here {
4295 match state.auth.issue_remember(user.user.id).await {
4296 Ok(credential) => jar.add(remember_cookie(
4297 credential,
4298 &state.cookie_path,
4299 state.secure_cookie,
4300 )),
4301 Err(e) => {
4302 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
4303 jar.remove(remember_removal(&state.cookie_path))
4304 }
4305 }
4306 } else if jar.get(REMEMBER_COOKIE).is_some() {
4307 jar.remove(remember_removal(&state.cookie_path))
4310 } else {
4311 jar
4312 };
4313 (
4314 jar,
4315 Redirect::to(&format!("{}/preferences", state.admin_path)),
4316 )
4317 .into_response()
4318}
4319
4320#[derive(Deserialize)]
4321struct PreferencesForm {
4322 timezone: String,
4323 #[serde(default)]
4325 locale: String,
4326}
4327
4328async fn preferences_update(
4329 State(state): State<AdminState>,
4330 Extension(user): Extension<AuthenticatedUser>,
4331 Extension(session): Extension<session::SessionHandle>,
4332 Form(form): Form<PreferencesForm>,
4333) -> Response {
4334 let back = format!("{}/preferences", state.admin_path);
4335 let offered = offered_locales(&state.catalogs);
4336 let tz = form.timezone.trim();
4338 let tz_stored = if tz.is_empty() {
4339 None
4340 } else if tz.parse::<Tz>().is_ok() {
4341 Some(tz)
4342 } else {
4343 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
4344 return Redirect::to(&back).into_response();
4345 };
4346 let loc = form.locale.trim();
4347 let loc_stored = if loc.is_empty() {
4348 None
4349 } else if offered.iter().any(|l| l == loc) {
4350 Some(loc)
4351 } else {
4352 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
4353 return Redirect::to(&back).into_response();
4354 };
4355 if state
4356 .auth
4357 .set_user_timezone(user.user.id, tz_stored)
4358 .await
4359 .is_err()
4360 {
4361 return render_error();
4362 }
4363 match state.auth.set_user_locale(user.user.id, loc_stored).await {
4364 Ok(()) => {
4365 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
4366 Redirect::to(&back).into_response()
4367 }
4368 Err(_) => render_error(),
4369 }
4370}
4371
4372#[allow(clippy::too_many_arguments)]
4376fn preferences_view(
4377 shell: &Shell,
4378 user: &AuthenticatedUser,
4379 default_tz: Tz,
4380 default_locale: &str,
4381 offered: &[String],
4382 account: AccountSecurity,
4383 min_password: usize,
4384 items: Vec<PreferenceLink>,
4385 menu_entries: Vec<MenuEntryView>,
4386) -> PreferencesTemplate {
4387 let current = user.user.timezone.as_deref();
4388 let zones = TZ_VARIANTS
4389 .iter()
4390 .filter(|tz| zone_offered(tz.name()))
4391 .map(|tz| TzOption {
4392 name: tz.name().to_string(),
4393 selected: current == Some(tz.name()),
4394 })
4395 .collect();
4396 let current_locale = user.user.locale.as_deref();
4397 let locales = offered
4398 .iter()
4399 .map(|code| LocaleOption {
4400 name: locale_name(code),
4401 selected: current_locale == Some(code.as_str()),
4402 code: code.clone(),
4403 })
4404 .collect();
4405 PreferencesTemplate {
4406 shell: shell.clone(),
4407 zones,
4408 effective_tz: shell.tz.name().to_string(),
4409 default_tz: default_tz.name().to_string(),
4410 inherits: current.is_none(),
4411 locales,
4412 default_locale: locale_name(default_locale),
4413 inherits_locale: current_locale.is_none(),
4414 sessions: account.sessions,
4415 password_changed: account.password_changed,
4416 must_change: account.must_change,
4417 min_password: min_password as i64,
4418 items,
4419 menu_entries,
4420 }
4421}
4422
4423struct AccountSecurity {
4426 sessions: Vec<SessionRow>,
4427 password_changed: Option<String>,
4428 must_change: bool,
4430}
4431
4432async fn account_security(
4433 state: &AdminState,
4434 user: &AuthenticatedUser,
4435 shell: &Shell,
4436 jar: &CookieJar,
4437) -> AccountSecurity {
4438 let locale = list::date_locale(shell.locale());
4439 let password_changed = match state.auth.password_changed_at(user.user.id).await {
4440 Ok(at) => {
4441 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
4442 }
4443 Err(e) => {
4444 tracing::error!(error = %e, "reading the password change time failed");
4445 None
4446 }
4447 };
4448 AccountSecurity {
4449 sessions: session_rows(state, user, shell, jar).await,
4450 password_changed,
4451 must_change: state
4452 .auth
4453 .must_change_password(user.user.id)
4454 .await
4455 .unwrap_or(false),
4456 }
4457}
4458
4459async fn session_rows(
4462 state: &AdminState,
4463 user: &AuthenticatedUser,
4464 shell: &Shell,
4465 jar: &CookieJar,
4466) -> Vec<SessionRow> {
4467 let token = jar
4468 .get(SESSION_COOKIE)
4469 .map(|c| c.value().to_string())
4470 .unwrap_or_default();
4471 let locale = list::date_locale(shell.locale());
4472 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
4473 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
4474 };
4475 match state.auth.list_sessions(user.user.id, &token).await {
4476 Ok(sessions) => sessions
4477 .into_iter()
4478 .map(|s| SessionRow {
4479 id: s.id,
4480 started: stamp(s.created_at),
4481 last_seen: stamp(s.last_seen_at),
4482 expires: stamp(s.expires_at),
4483 device: clientip::describe(s.user_agent.as_deref())
4484 .or(s.user_agent)
4485 .unwrap_or_else(|| "-".to_string()),
4486 ip_address: s.ip_address.unwrap_or_default(),
4487 current: s.current,
4488 })
4489 .collect(),
4490 Err(e) => {
4491 tracing::error!(error = %e, "listing sessions failed");
4492 Vec::new()
4493 }
4494 }
4495}
4496
4497fn builtin_resources() -> Vec<Resource> {
4499 vec![
4500 Resource {
4501 base_path: "/users".to_string(),
4502 nav_label: "Administrators".into(),
4503 list: backend_users_list_config(),
4504 form: None,
4505 permission: Some("backend.manage_users".to_string()),
4506 import: None,
4507 palette: None,
4508 relations: Vec::new(),
4509 menu: nav::Placement::default(),
4510 },
4511 Resource {
4512 base_path: "/roles".to_string(),
4513 nav_label: "Roles".into(),
4514 list: roles_list_config(),
4515 form: None,
4518 permission: Some("backend.manage_roles".to_string()),
4519 import: None,
4520 palette: None,
4521 relations: Vec::new(),
4522 menu: nav::Placement::default(),
4523 },
4524 resource!("descriptors/audit-log.yaml"),
4525 resource!("descriptors/access-log.yaml"),
4526 resource!("descriptors/request-log.yaml"),
4527 ]
4528}
4529
4530fn builtin_settings() -> Vec<settings::SettingsItem> {
4534 vec![
4535 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
4536 .description("Manage backend administrator accounts.")
4537 .category("Users")
4538 .slot(laterite_core::arrange::slot::PEOPLE)
4539 .order(10)
4540 .icon("users")
4541 .permission("backend.manage_users")
4542 .link("/users"),
4543 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
4544 .description("Manage roles and their permissions.")
4545 .category("Users")
4546 .slot(laterite_core::arrange::slot::PEOPLE)
4547 .order(20)
4548 .icon("shield")
4549 .permission("backend.manage_roles")
4550 .link("/roles"),
4551 settings::brand::settings_item(),
4552 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
4553 .description("Enable or disable installed plugins.")
4554 .category("System")
4555 .slot(laterite_core::arrange::slot::SYSTEM)
4556 .order(10)
4557 .icon("plug")
4558 .permission(plugins::MANAGE_PERMISSION)
4559 .link("/plugins"),
4560 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
4561 .description("Review the record of administrative changes.")
4562 .category("System")
4563 .slot(laterite_core::arrange::slot::SYSTEM)
4564 .order(20)
4565 .icon("history")
4566 .permission("backend.view_audit_log")
4567 .link("/audit-log"),
4568 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
4569 .description("Review sign-ins, failures and lockouts.")
4570 .category("System")
4571 .slot(laterite_core::arrange::slot::SYSTEM)
4572 .order(30)
4573 .icon("log-in")
4574 .permission("backend.view_access_log")
4575 .link("/access-log"),
4576 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
4577 .description("Review the URLs the panel could not serve.")
4578 .category("System")
4579 .slot(laterite_core::arrange::slot::SYSTEM)
4580 .order(31)
4581 .icon("unlink")
4582 .permission("backend.view_request_log")
4583 .link("/request-log"),
4584 ]
4585}
4586
4587fn backend_users_list_config() -> list::ListConfig {
4588 list::ListConfig {
4589 entity: "backend_users".to_string(),
4590 title: "Administrators".into(),
4591 columns: vec![
4592 list::ListColumn::new("username", "Username"),
4593 list::ListColumn::new("email", "Email"),
4594 list::ListColumn::new("first_name", "First name"),
4595 list::ListColumn::new("last_name", "Last name"),
4596 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
4597 list::ListColumn::new("is_active", "Active").yes_no(),
4598 list::ListColumn::new("created_at", "Created").datetime(),
4599 ],
4600 order_by: "created_at".to_string(),
4601 creatable: true,
4604 edit_base: Some("/users".to_string()),
4605 filters: vec![
4606 list::ListFilter::boolean("is_active", "Active"),
4607 list::ListFilter::boolean("is_superuser", "Superuser"),
4608 ],
4609 ..Default::default()
4613 }
4614}
4615
4616fn roles_list_config() -> list::ListConfig {
4617 list::ListConfig {
4618 entity: "backend_roles".to_string(),
4619 title: "Roles".into(),
4620 columns: vec![
4621 list::ListColumn::new("code", "Code"),
4622 list::ListColumn::new("name", "Name"),
4623 list::ListColumn::new("created_at", "Created").datetime(),
4624 ],
4625 order_by: "created_at".to_string(),
4626 edit_base: Some("/roles".to_string()),
4627 creatable: true,
4628 deletable: true,
4629 ..Default::default()
4630 }
4631}
4632
4633#[derive(Template)]
4634#[template(path = "login.html")]
4635struct LoginTemplate {
4636 base: String,
4638 asset_urls: Arc<AssetUrls>,
4640 brand: String,
4641 error: Option<String>,
4642 notice: Option<String>,
4644 i18n: Translator,
4647}
4648
4649fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
4652 match urls.get(key) {
4653 Some(path) => format!("{base}/assets/{path}"),
4654 None => {
4655 debug_assert!(false, "asset key `{key}` is not registered");
4656 format!("{base}/assets/{key}")
4657 }
4658 }
4659}
4660
4661impl LoginTemplate {
4662 fn asset(&self, key: &str) -> String {
4663 preauth_asset(&self.asset_urls, &self.base, key)
4664 }
4665 fn t(&self, source: &str) -> String {
4666 self.i18n.t(&Text::dynamic(source))
4667 }
4668 fn locale(&self) -> &str {
4669 self.i18n.locale()
4670 }
4671}
4672
4673#[derive(Template)]
4674#[template(path = "dashboard.html")]
4675struct DashboardTemplate {
4676 shell: Shell,
4677 username: String,
4678}
4679
4680#[derive(Template)]
4681#[template(path = "setup.html")]
4682struct SetupTemplate {
4683 base: String,
4685 asset_urls: Arc<AssetUrls>,
4687 brand: String,
4688 zones: Vec<TzOption>,
4689 error: Option<String>,
4690 i18n: Translator,
4692 min_password: i64,
4694}
4695
4696impl SetupTemplate {
4697 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
4700 let mut text = Text::dynamic(source);
4701 for (name, value) in args {
4702 text = text.arg(*name, *value);
4703 }
4704 self.i18n.t(&text)
4705 }
4706
4707 fn asset(&self, key: &str) -> String {
4708 preauth_asset(&self.asset_urls, &self.base, key)
4709 }
4710 fn t(&self, source: &str) -> String {
4711 self.i18n.t(&Text::dynamic(source))
4712 }
4713 fn locale(&self) -> &str {
4714 self.i18n.locale()
4715 }
4716}
4717
4718#[derive(Template)]
4719#[template(path = "preferences.html")]
4720struct PreferencesTemplate {
4721 shell: Shell,
4722 zones: Vec<TzOption>,
4723 effective_tz: String,
4725 default_tz: String,
4727 inherits: bool,
4729 locales: Vec<LocaleOption>,
4730 default_locale: String,
4732 inherits_locale: bool,
4734 sessions: Vec<SessionRow>,
4736 password_changed: Option<String>,
4738 must_change: bool,
4740 min_password: i64,
4742 items: Vec<PreferenceLink>,
4744 menu_entries: Vec<MenuEntryView>,
4746}
4747
4748struct MenuEntryView {
4751 key: String,
4752 label: String,
4753 home: &'static str,
4755 chosen: String,
4757}
4758
4759struct SessionRow {
4761 id: String,
4762 started: String,
4763 last_seen: String,
4764 expires: String,
4765 device: String,
4768 ip_address: String,
4770 current: bool,
4771}
4772
4773struct TzOption {
4774 name: String,
4775 selected: bool,
4776}
4777
4778struct LocaleOption {
4779 code: String,
4781 name: String,
4783 selected: bool,
4784}
4785
4786#[derive(Clone)]
4788struct CrumbView {
4789 label: String,
4790 href: String,
4791}
4792
4793#[derive(Clone)]
4794struct NavView {
4795 key: String,
4797 label: String,
4798 path: String,
4799 active: bool,
4800 icon: String,
4803}
4804
4805pub(crate) fn render<T: Template>(template: T) -> Response {
4807 match template.render() {
4808 Ok(html) => Html(html).into_response(),
4809 Err(e) => AdminError::from(e).into_response(),
4811 }
4812}
4813
4814pub(crate) fn render_error() -> Response {
4815 error::masked_500()
4816}
4817
4818pub(crate) fn not_found() -> Response {
4819 AdminError::NotFound.into_response()
4820}
4821
4822pub(crate) fn forbidden() -> Response {
4823 AdminError::Forbidden.into_response()
4824}
4825
4826#[cfg(test)]
4827mod tests {
4828 use super::*;
4829
4830 fn write_form() -> form::FormConfig {
4831 form::FormConfig {
4832 enter: Default::default(),
4833 confirm_leave: true,
4834 focus: Default::default(),
4835 entity: "widgets".to_string(),
4836 title: "Widgets".into(),
4837 base_path: "/widgets".to_string(),
4838 id_field: "id".to_string(),
4839 fields: vec![],
4840 persist: None,
4841 timestamps: false,
4842 done_path: None,
4843 }
4844 }
4845
4846 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
4847 Resource {
4848 base_path: "/widgets".to_string(),
4849 nav_label: "Widgets".into(),
4850 list: backend_users_list_config(),
4851 form,
4852 permission: permission.map(str::to_string),
4853 import: None,
4854 palette: None,
4855 relations: Vec::new(),
4856 menu: nav::Placement::default(),
4857 }
4858 }
4859
4860 #[test]
4861 #[should_panic(expected = "must declare a permission")]
4862 fn write_resource_without_permission_aborts_boot() {
4863 let resource = resource_with(Some(write_form()), None);
4866 let _ = mount_resource(
4867 &resource,
4868 &field::builtin_registry(),
4869 &list::builtin_column_registry(),
4870 &std::collections::HashSet::new(),
4871 &persist::PersisterRegistry::new(),
4872 &[],
4873 );
4874 }
4875
4876 #[test]
4878 fn the_access_log_is_read_from_its_descriptor_file() {
4879 let access = builtin_resources()
4880 .into_iter()
4881 .find(|r| r.base_path == "/access-log")
4882 .expect("the access log is registered");
4883 assert_eq!(access.list.entity, "backend_access_log");
4884 assert_eq!(
4885 access.permission.as_deref(),
4886 Some("backend.view_access_log")
4887 );
4888 assert!(access.form.is_none(), "append-only");
4889 assert_eq!(
4890 access
4891 .list
4892 .columns
4893 .iter()
4894 .map(|c| c.field.as_str())
4895 .collect::<Vec<_>>(),
4896 [
4897 "created_at",
4898 "username_attempted",
4899 "event",
4900 "ip_address",
4901 "user_agent"
4902 ]
4903 );
4904 }
4905
4906 #[test]
4909 fn the_audit_log_is_read_from_its_descriptor_file() {
4910 let audit = builtin_resources()
4911 .into_iter()
4912 .find(|r| r.base_path == "/audit-log")
4913 .expect("the audit log is registered");
4914 assert_eq!(audit.list.entity, "backend_audit_log");
4915 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
4916 assert_eq!(
4917 audit
4918 .list
4919 .columns
4920 .iter()
4921 .map(|c| c.field.as_str())
4922 .collect::<Vec<_>>(),
4923 [
4924 "created_at",
4925 "actor_username",
4926 "action",
4927 "target_type",
4928 "target_id",
4929 "target_label"
4930 ],
4931 "in the order the file writes them"
4932 );
4933 assert_eq!(audit.list.columns[0].column_type, "datetime");
4934 assert_eq!(audit.list.per_page, 50);
4935 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
4936 assert!(audit.list.exportable, "evidence may leave the panel");
4937 assert!(audit.form.is_none(), "append-only: no form");
4938 assert!(!audit.list.deletable, "and nothing removes from it");
4939 }
4940
4941 #[test]
4942 #[should_panic(expected = "uses unregistered type `nope`")]
4943 fn unregistered_column_type_aborts_boot() {
4944 let mut resource = resource_with(None, None);
4945 let mut score = list::ListColumn::new("score", "Score");
4946 score.column_type = "nope".into();
4947 resource.list.columns.push(score);
4948 let _ = mount_resource(
4949 &resource,
4950 &field::builtin_registry(),
4951 &list::builtin_column_registry(),
4952 &std::collections::HashSet::new(),
4953 &persist::PersisterRegistry::new(),
4954 &[],
4955 );
4956 }
4957
4958 #[test]
4959 fn read_only_resource_may_omit_permission() {
4960 let resource = resource_with(None, None);
4962 let _ = mount_resource(
4963 &resource,
4964 &field::builtin_registry(),
4965 &list::builtin_column_registry(),
4966 &std::collections::HashSet::new(),
4967 &persist::PersisterRegistry::new(),
4968 &[],
4969 );
4970 }
4971
4972 #[test]
4973 fn shell_localizes_through_the_translator_and_falls_back() {
4974 let shell = Shell::test();
4976 assert_eq!(shell.t("Save"), "Save");
4977 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
4978 }
4979
4980 #[test]
4981 fn shell_tf_interpolates_integer_arguments() {
4982 let shell = Shell::test();
4983 assert_eq!(
4984 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
4985 "Page 2 of 5"
4986 );
4987 }
4988
4989 #[test]
4990 fn display_tz_prefers_a_valid_operator_preference() {
4991 assert_eq!(
4992 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
4993 Tz::Asia__Kolkata
4994 );
4995 }
4996
4997 #[test]
4998 fn display_tz_falls_back_when_unset_or_invalid() {
4999 let default = Tz::Europe__London;
5000 assert_eq!(resolve_display_tz(None, default), default);
5002 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
5004 assert_eq!(resolve_display_tz(Some(""), default), default);
5005 }
5006
5007 #[test]
5008 fn accept_language_orders_by_weight_and_drops_wildcard() {
5009 assert_eq!(
5010 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
5011 vec!["en-US", "en", "kn"]
5012 );
5013 assert_eq!(
5015 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
5016 vec!["kn", "en"]
5017 );
5018 }
5019
5020 #[test]
5021 fn locale_chain_prefers_operator_then_header_then_default() {
5022 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
5024 assert_eq!(
5027 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
5028 vec!["kn", "ta", "hi", "en"]
5029 );
5030 assert_eq!(
5032 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
5033 vec!["kn", "en", "ta"]
5034 );
5035 assert_eq!(
5037 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
5038 vec!["kn", "en"]
5039 );
5040 }
5041
5042 #[test]
5043 fn locale_chain_skips_unserveable_and_defaults_to_en() {
5044 let loaded = ["hi".to_string()];
5046 assert_eq!(
5049 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
5050 vec!["en"]
5051 );
5052 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
5054 assert_eq!(
5056 resolve_locale_chain(Some("hi"), None, "en", &loaded),
5057 vec!["hi", "en"]
5058 );
5059 assert_eq!(
5061 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
5062 vec![laterite_core::PSEUDO_LOCALE, "en"]
5063 );
5064 }
5065
5066 #[test]
5067 fn page_head_assets_dedupe_and_keep_order() {
5068 let head = PageHead::new("Specification")
5069 .assets(CODE_EDITOR_ASSETS)
5070 .assets(&["fields/codeeditor.js", "fields/ref-picker.js"]);
5071 assert_eq!(
5072 head.assets,
5073 vec![
5074 "vendor/codeeditor.js",
5075 "fields/codeeditor.js",
5076 "fields/codeeditor.css",
5077 "fields/ref-picker.js"
5078 ]
5079 );
5080 }
5081
5082 #[test]
5083 fn page_assets_dedupe_preserve_order_and_classify() {
5084 let mut reg = AssetRegistry::new();
5085 reg.insert(
5086 "a.js",
5087 AdminAsset {
5088 mime: "text/javascript; charset=utf-8",
5089 bytes: std::borrow::Cow::Borrowed(b"//"),
5090 },
5091 );
5092 reg.insert(
5093 "b.css",
5094 AdminAsset {
5095 mime: "text/css; charset=utf-8",
5096 bytes: std::borrow::Cow::Borrowed(b"body{}"),
5097 },
5098 );
5099 let urls = asset_urls(®);
5102 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
5103 assert_eq!(assets.len(), 2);
5104 assert_eq!(
5105 assets[0].url,
5106 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
5107 );
5108 assert!(!assets[0].css);
5109 assert_eq!(
5110 assets[1].url,
5111 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
5112 );
5113 assert!(assets[1].css);
5114 assert_ne!(assets[0].url, assets[1].url);
5117 }
5118
5119 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
5123 laterite_core::testing::connect_test(&[]).await
5124 }
5125
5126 #[tokio::test]
5127 async fn builtin_migrations_create_the_admin_tables() {
5128 let (db, _guard) = empty_db().await;
5129 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
5130 .await
5131 .unwrap();
5132 for table in ["backend_users", "settings"] {
5136 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
5137 .fetch_optional(&db.pool)
5138 .await;
5139 assert!(
5140 probe.is_ok(),
5141 "{table} should exist after builtin_migrations"
5142 );
5143 }
5144 }
5145
5146 #[tokio::test]
5147 async fn brand_setting_overrides_config_and_blank_falls_back() {
5148 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
5149 let state = AdminState {
5150 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
5151 db: db.clone(),
5152 nav: Arc::new(nav::NavTree::default()),
5153 nav_items: Arc::new(Vec::new()),
5154 settings: Arc::new(Vec::new()),
5155 permissions: Arc::new(builtin_permissions()),
5156 trusted_proxies: Arc::new(Vec::new()),
5157 icons: Arc::new(laterite_core::icons::IconSet::new()),
5158 sprite_url: Arc::from(""),
5159 admin_path: Arc::from("/admin"),
5160 cookie_path: Arc::from("/admin"),
5161 storage_dir: Arc::from("storage"),
5162 signing_key: Arc::new(random_key()),
5163 order: std::collections::BTreeMap::new(),
5164 secure_cookie: false,
5165 origin: Arc::from(""),
5166 timezone: Tz::UTC,
5167 default_locale: Arc::from("en"),
5168 catalogs: Arc::new(CatalogStore::default()),
5169 app_name: "Configured Name".to_string(),
5170 brand_cache: Arc::new(RwLock::new(None)),
5171 field_types: Arc::new(field::builtin_registry()),
5172 column_types: Arc::new(list::builtin_column_registry()),
5173 plugin_defined: Arc::new(laterite_core::Registry::new()),
5174 assets: Arc::new(builtin_assets()),
5175 asset_urls: Arc::new(asset_urls(&builtin_assets())),
5176 pickers: Arc::new(picker::PickerRegistry::new()),
5177 palette: Arc::new(Vec::new()),
5178 overrides: Arc::new(field::NoOverrides),
5179 };
5180
5181 assert_eq!(state.brand().await, "Configured Name");
5183
5184 settings::store::save(
5187 &db,
5188 &settings::BrandSetting {
5189 app_name: "Acme Corp".to_string(),
5190 },
5191 )
5192 .await
5193 .unwrap();
5194 state.invalidate_brand();
5195 assert_eq!(state.brand().await, "Acme Corp");
5196
5197 settings::store::save(
5199 &db,
5200 &settings::BrandSetting {
5201 app_name: " ".to_string(),
5202 },
5203 )
5204 .await
5205 .unwrap();
5206 state.invalidate_brand();
5207 assert_eq!(state.brand().await, "Configured Name");
5208 }
5209
5210 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
5211 settings::SettingsItem {
5212 code: code.to_string(),
5213 label: code.into(),
5214 description: String::new().into(),
5215 category: "General".into(),
5216 hint: None,
5217 confirm_leave: true,
5218 slot: laterite_core::arrange::slot::APPLICATION,
5219 order: 1,
5220 icon: None,
5221 permission: permission.map(str::to_string),
5222 link: None,
5223 fields: Vec::new(),
5224 route: None,
5225 per_operator: false,
5226 }
5227 }
5228
5229 #[test]
5230 fn settings_visibility_respects_permissions() {
5231 let items = vec![
5232 settings_item("public", None),
5233 settings_item("gated", Some("backend.manage_users")),
5234 ];
5235
5236 let none = PermissionSet::new(false, Vec::<String>::new());
5238 let codes: Vec<String> = visible_settings(&items, &none)
5239 .into_iter()
5240 .map(|i| i.code)
5241 .collect();
5242 assert_eq!(codes, ["public"]);
5243
5244 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
5246 assert_eq!(visible_settings(&items, &granted).len(), 2);
5247
5248 let superuser = PermissionSet::new(true, Vec::<String>::new());
5250 assert_eq!(visible_settings(&items, &superuser).len(), 2);
5251 }
5252
5253 #[test]
5254 fn context_sidebar_follows_settings_links() {
5255 let items = builtin_settings();
5256 let superuser = PermissionSet::new(true, Vec::<String>::new());
5257 let tr = Translator::new("en");
5258 let tree = nav::NavTree::default();
5259 let sidebar = |path: &str| {
5260 resolve_nav_context(
5261 &tree,
5262 &items,
5263 "/admin",
5264 &superuser,
5265 None,
5266 path,
5267 &tr,
5268 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
5269 &std::collections::BTreeMap::new(),
5270 None,
5271 )
5272 .1
5273 };
5274 let active_path = |path: &str| -> Option<String> {
5275 sidebar(path)
5276 .into_iter()
5277 .flat_map(|g| g.items)
5278 .find(|i| i.active)
5279 .map(|i| i.path)
5280 };
5281
5282 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
5284 assert_eq!(
5285 active_path("/admin/roles/42/edit").as_deref(),
5286 Some("/admin/roles")
5287 );
5288 assert!(!sidebar("/admin/settings").is_empty());
5290 assert_eq!(active_path("/admin/settings"), None);
5291 assert_eq!(
5296 active_path("/admin/settings/branding").as_deref(),
5297 Some("/admin/settings/branding")
5298 );
5299 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
5302 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
5303 assert!(sidebar("/admin").is_empty());
5305 }
5306}
5307
5308#[cfg(test)]
5309mod session_cookie_tests {
5310 use super::*;
5311
5312 #[test]
5316 fn the_session_cookie_never_outlives_the_browser() {
5317 let cookie = session_cookie("tok".into(), "/admin", false);
5318 assert_eq!(cookie.max_age(), None);
5319 assert!(cookie.http_only().unwrap_or(false));
5320 assert_eq!(cookie.path(), Some("/admin"));
5321 }
5322
5323 #[test]
5326 fn the_remember_cookie_carries_its_own_lifetime() {
5327 let credential = laterite_auth::RememberCredential {
5328 cookie: "sel:ver".into(),
5329 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
5330 };
5331 let cookie = remember_cookie(credential, "/admin", true);
5332 let age = cookie.max_age().expect("a remember cookie must persist");
5333 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
5335 assert!(cookie.http_only().unwrap_or(false));
5336 assert!(cookie.secure().unwrap_or(false));
5337 }
5338
5339 #[test]
5341 fn the_removal_matches_the_cookie_it_clears() {
5342 let removal = remember_removal("/admin");
5343 assert_eq!(removal.name(), REMEMBER_COOKIE);
5344 assert_eq!(removal.path(), Some("/admin"));
5345 }
5346}