1mod audit;
18pub mod bootstrap;
19mod bulk;
20pub mod checklist;
21mod clientip;
22pub mod descriptor;
23mod error;
24mod export;
25pub mod field;
26pub mod form;
27pub mod html;
28pub mod http_cache;
29mod icons;
30pub mod import;
31pub(crate) mod keyed;
32pub mod list;
33pub mod medialib;
34pub mod nav;
35pub(crate) mod palette;
36pub mod persist;
37pub mod picker;
38pub mod plugins;
39pub mod relation;
40pub(crate) mod requestlog;
41mod roles;
42mod routemap;
43pub mod routes;
44mod session;
45pub mod settings;
46mod sql;
47pub mod ui;
48mod upload;
49mod users;
50
51pub use axum;
59
60pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
61pub use error::AdminError;
62pub use session::{FlashLevel, SessionHandle};
63pub use upload::VerifiedUpload;
64
65use std::collections::HashMap;
66use std::sync::{Arc, RwLock};
67
68use askama::Template;
69use axum::body::Body;
70use axum::extract::{Path, Query, Request, State};
71use axum::http::{header, HeaderMap};
72use axum::middleware::{self, Next};
73use axum::response::{Html, IntoResponse, Redirect, Response};
74use axum::routing::{any, get, post};
75use axum::{Extension, Form, Router};
76use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
77use chrono_tz::{Tz, TZ_VARIANTS};
78use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
79use laterite_core::{t, CatalogStore, Db, Text, Translator};
80use serde::{Deserialize, Serialize};
81
82pub trait AdminRegistry {
88 fn add_resource(&mut self, resource: Resource);
90 fn add_permission(&mut self, permission: Permission);
92 fn add_settings(&mut self, item: settings::SettingsItem);
94 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
96 fn add_persister(&mut self, persister: persist::PersisterReg);
98 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
100 fn add_screen(&mut self, screen: routes::ScreenReg);
103 fn add_public_route(&mut self, route: routes::PublicRouteReg);
106 fn add_nav(&mut self, item: nav::NavItem);
108 fn add_resource_hook(&mut self, hook: ResourceHook);
110}
111
112impl AdminRegistry for laterite_core::Registry {
113 fn add_resource(&mut self, resource: Resource) {
114 self.add(resource);
115 }
116 fn add_permission(&mut self, permission: Permission) {
117 self.add(permission);
118 }
119 fn add_settings(&mut self, item: settings::SettingsItem) {
120 self.add(item);
121 }
122 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
123 self.add(source);
124 }
125 fn add_screen(&mut self, screen: routes::ScreenReg) {
126 self.add(screen);
127 }
128 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
129 self.add(route);
130 }
131 fn add_nav(&mut self, item: nav::NavItem) {
132 self.add(item);
133 }
134 fn add_resource_hook(&mut self, hook: ResourceHook) {
135 self.add(hook);
136 }
137 fn add_persister(&mut self, persister: persist::PersisterReg) {
138 self.add(persister);
139 }
140 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
141 self.add(field_type);
142 }
143}
144
145#[derive(Clone)]
152pub struct ResourceHook {
153 pub target: String,
154 apply: Arc<dyn Fn(Resource) -> Resource + Send + Sync>,
155}
156
157impl ResourceHook {
158 pub fn new(
159 target: impl Into<String>,
160 apply: impl Fn(Resource) -> Resource + Send + Sync + 'static,
161 ) -> Self {
162 Self {
163 target: target.into(),
164 apply: Arc::new(apply),
165 }
166 }
167
168 pub fn apply(&self, resource: Resource) -> Resource {
170 (self.apply)(resource)
171 }
172}
173
174impl std::fmt::Debug for ResourceHook {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 write!(f, "ResourceHook({})", self.target)
177 }
178}
179
180pub(crate) const SESSION_COOKIE: &str = "laterite_session";
181const REMEMBER_COOKIE: &str = "laterite_remember";
185
186pub(crate) const ICON_SPRITE: &str = "icons.svg";
189
190#[derive(Clone)]
192pub(crate) struct AdminState {
193 auth: AuthService,
194 db: Db,
195 nav: Arc<nav::NavTree>,
196 nav_items: Arc<Vec<nav::NavItem>>,
199 settings: Arc<Vec<settings::SettingsItem>>,
200 permissions: Arc<Vec<Permission>>,
201 admin_path: Arc<str>,
205 cookie_path: Arc<str>,
209 storage_dir: Arc<str>,
211 signing_key: Arc<Vec<u8>>,
213 order: std::collections::BTreeMap<String, Vec<String>>,
215 secure_cookie: bool,
216 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
218 icons: Arc<laterite_core::icons::IconSet>,
220 sprite_url: Arc<str>,
222 origin: Arc<str>,
224 timezone: Tz,
225 default_locale: Arc<str>,
229 catalogs: Arc<CatalogStore>,
232 app_name: String,
235 brand_cache: Arc<RwLock<Option<String>>>,
239 asset_urls: Arc<AssetUrls>,
241 field_types: Arc<field::FieldRegistry>,
244 column_types: Arc<list::ColumnRegistry>,
247 plugin_defined: Arc<laterite_core::Registry>,
250 assets: Arc<AssetRegistry>,
252 pickers: Arc<picker::PickerRegistry>,
255 palette: Arc<Vec<palette::RecordSource>>,
258 overrides: Arc<dyn field::OverrideResolver>,
262}
263
264impl AdminState {
265 #[cfg(test)]
266 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
267 Self {
268 auth,
269 db,
270 nav: Arc::new(nav::NavTree::default()),
271 nav_items: Arc::new(Vec::new()),
272 settings: Arc::new(Vec::new()),
273 permissions: Arc::new(builtin_permissions()),
274 trusted_proxies: Arc::new(Vec::new()),
275 icons: Arc::new(laterite_core::icons::IconSet::new()),
276 sprite_url: Arc::from(""),
277 admin_path: Arc::from("/admin"),
278 cookie_path: Arc::from("/admin"),
279 storage_dir: Arc::from("storage"),
280 signing_key: Arc::new(random_key()),
281 order: std::collections::BTreeMap::new(),
282 secure_cookie: false,
283 origin: Arc::from(""),
284 timezone: Tz::UTC,
285 default_locale: Arc::from("en"),
286 catalogs: Arc::new(CatalogStore::default()),
287 app_name: "Laterite".to_string(),
288 brand_cache: Arc::new(RwLock::new(None)),
289 field_types: Arc::new(field::builtin_registry()),
290 column_types: Arc::new(list::builtin_column_registry()),
291 plugin_defined: Arc::new(laterite_core::Registry::new()),
292 assets: Arc::new(builtin_assets()),
293 asset_urls: Arc::new(asset_urls(&builtin_assets())),
294 pickers: Arc::new(picker::PickerRegistry::new()),
295 palette: Arc::new(Vec::new()),
296 overrides: Arc::new(field::NoOverrides),
297 }
298 }
299
300 async fn brand(&self) -> String {
304 {
305 let cached = self.brand_cache.read().unwrap().clone();
306 if let Some(name) = cached {
307 return name;
308 }
309 }
310 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
311 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
312 _ => self.app_name.clone(),
313 };
314 *self.brand_cache.write().unwrap() = Some(resolved.clone());
315 resolved
316 }
317
318 fn invalidate_brand(&self) {
320 *self.brand_cache.write().unwrap() = None;
321 }
322}
323
324#[derive(Clone)]
330#[non_exhaustive]
331pub struct AdminConfig {
332 pub secure_cookie: bool,
335 pub trusted_proxies: Vec<String>,
339 pub timezone: String,
343 pub locale: String,
347 pub app_name: String,
351 pub path: String,
355 pub origin: String,
360 pub storage_dir: String,
365 pub order: std::collections::BTreeMap<String, Vec<String>>,
368 pub menu: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
370 pub secret: Vec<u8>,
374}
375
376impl Default for AdminConfig {
377 fn default() -> Self {
378 Self {
379 secure_cookie: false,
380 trusted_proxies: Vec::new(),
381 timezone: "UTC".to_string(),
382 locale: "en".to_string(),
383 app_name: "Laterite".to_string(),
384 path: "/admin".to_string(),
385 origin: String::new(),
386 storage_dir: "storage".to_string(),
387 order: std::collections::BTreeMap::new(),
388 menu: std::collections::BTreeMap::new(),
389 secret: random_key(),
390 }
391 }
392}
393
394pub(crate) fn random_key() -> Vec<u8> {
396 use rand::RngCore;
397 let mut key = vec![0u8; 32];
398 rand::thread_rng().fill_bytes(&mut key);
399 key
400}
401
402#[derive(Clone)]
411pub struct Shell {
412 pub(crate) base: String,
416 brand: String,
420 nav: Vec<NavView>,
421 full_name: String,
422 initial: String,
423 tz: Tz,
427 i18n: Translator,
431 sidebar: Vec<settings::CategoryView>,
435 crumbs: Vec<CrumbView>,
438 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
440 pub(crate) sprite_url: Arc<str>,
443 site_url: String,
447 asset_urls: Arc<AssetUrls>,
451 pub(crate) csrf_token: String,
455 pub(crate) flash: Vec<FlashLine>,
459 pub(crate) assets: Vec<PageAsset>,
463}
464
465#[derive(Template)]
467#[template(path = "page.html")]
468struct PageTemplate {
469 title: String,
470 back: Option<(String, String)>,
472 actions: Option<html::Markup>,
474 body: html::Markup,
475 shell: Shell,
476}
477
478pub struct PageHead {
489 title: Text,
490 back: Option<(Text, String)>,
491 actions: Option<html::Markup>,
492}
493
494impl PageHead {
495 pub fn new(title: impl Into<Text>) -> Self {
496 Self {
497 title: title.into(),
498 back: None,
499 actions: None,
500 }
501 }
502
503 pub fn back(mut self, label: impl Into<Text>, href: impl Into<String>) -> Self {
505 self.back = Some((label.into(), href.into()));
506 self
507 }
508
509 pub fn actions(mut self, actions: html::Markup) -> Self {
512 self.actions = Some(actions);
513 self
514 }
515}
516
517#[derive(Clone)]
519pub(crate) struct PageAsset {
520 pub(crate) url: String,
521 pub(crate) css: bool,
522}
523
524#[derive(Clone)]
527pub(crate) struct FlashLine {
528 pub(crate) level: session::FlashLevel,
529 pub(crate) text: String,
530 pub(crate) sticky: bool,
532}
533
534impl Shell {
535 #[allow(clippy::too_many_arguments)]
536 fn new(
537 base: &str,
538 brand: String,
539 nav: &[nav::NavNode],
540 user: &AuthenticatedUser,
541 default_tz: Tz,
542 sidebar: Vec<settings::CategoryView>,
543 active_nav: Option<&str>,
544 csrf_token: String,
545 flash: Vec<session::Flash>,
546 i18n: Translator,
547 asset_urls: Arc<AssetUrls>,
548 site_url: String,
549 icon_set: Arc<laterite_core::icons::IconSet>,
550 sprite_url: Arc<str>,
551 ) -> Self {
552 let full_name = user.user.full_name();
553 let initial = full_name
554 .chars()
555 .next()
556 .map(|c| c.to_uppercase().to_string())
557 .unwrap_or_else(|| "?".to_string());
558 let flash = flash
561 .into_iter()
562 .map(|f| FlashLine {
563 sticky: f.sticky,
564 level: f.level,
565 text: i18n.t(&f.text),
566 })
567 .collect();
568 let nav: Vec<NavView> = nav
569 .iter()
570 .map(|n| NavView {
571 key: n.item.key.clone(),
572 label: i18n.t(&n.item.label),
573 path: nav::tab_path(n).unwrap_or_default().to_string(),
574 active: active_nav == Some(n.item.key.as_str()),
575 icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
576 })
577 .collect();
578 let mut crumbs: Vec<CrumbView> = nav
579 .iter()
580 .filter(|n: &&NavView| n.active)
581 .map(|n| CrumbView {
582 label: n.label.clone(),
583 href: n.path.clone(),
584 })
585 .collect();
586 if let Some(entry) = sidebar
587 .iter()
588 .flat_map(|g| g.items.iter())
589 .find(|i| i.active)
590 {
591 crumbs.push(CrumbView {
592 label: entry.label.clone(),
593 href: entry.path.clone(),
594 });
595 }
596 Shell {
597 asset_urls,
598 icon_set,
599 sprite_url,
600 site_url,
601 base: base.to_string(),
602 brand,
603 nav,
604 crumbs,
605 full_name,
606 initial,
607 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
608 i18n,
609 sidebar,
610 csrf_token,
611 flash,
612 assets: Vec::new(),
613 }
614 }
615
616 pub(crate) fn site_url(&self) -> &str {
618 &self.site_url
619 }
620
621 pub(crate) fn sidebar_anchored(&self) -> bool {
624 self.nav.iter().any(|n| n.active)
625 }
626
627 pub fn icon(&self, name: &str) -> String {
631 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
632 }
633
634 pub(crate) fn asset(&self, key: &str) -> String {
639 match self.asset_urls.get(key) {
640 Some(path) => format!("{}/assets/{path}", self.base),
641 None => {
642 debug_assert!(false, "asset key `{key}` is not registered");
643 format!("{}/assets/{key}", self.base)
644 }
645 }
646 }
647
648 pub fn t(&self, source: &str) -> String {
651 self.i18n.t(&Text::dynamic(source))
652 }
653
654 pub fn tt(&self, text: &Text) -> String {
657 self.i18n.t(text)
658 }
659
660 pub fn i18n(&self) -> &Translator {
663 &self.i18n
664 }
665
666 pub fn locale(&self) -> &str {
668 self.i18n.locale()
669 }
670
671 pub fn base(&self) -> &str {
673 &self.base
674 }
675
676 pub fn csrf_token(&self) -> &str {
680 &self.csrf_token
681 }
682
683 pub fn datetime(&self, utc: &str) -> String {
686 match chrono::DateTime::parse_from_rfc3339(utc) {
687 Ok(at) => at
688 .with_timezone(&self.tz)
689 .format("%Y-%m-%d %H:%M")
690 .to_string(),
691 Err(_) => utc.to_string(),
692 }
693 }
694
695 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
699 self.page_with(PageHead::new(title.clone()), body)
700 }
701
702 pub fn page_with(&self, head: PageHead, body: html::Markup) -> Response {
705 render(PageTemplate {
706 title: self.tt(&head.title),
707 back: head.back.map(|(label, href)| (self.tt(&label), href)),
708 actions: head.actions,
709 body,
710 shell: self.clone(),
711 })
712 }
713
714 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
718 let mut text = Text::dynamic(source);
719 for (name, value) in args {
720 text = text.arg(*name, *value);
721 }
722 self.i18n.t(&text)
723 }
724
725 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
728 let mut text = Text::dynamic(source);
729 for (name, value) in args {
730 text = text.arg(*name, *value);
731 }
732 self.i18n.t(&text)
733 }
734
735 #[cfg(test)]
736 pub(crate) fn test() -> Self {
737 Shell {
738 asset_urls: Arc::new(asset_urls(&builtin_assets())),
739 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
740 sprite_url: Arc::from("/admin/assets/icons.svg"),
741 site_url: "http://localhost".to_string(),
742 base: "/admin".to_string(),
743 brand: "Laterite".to_string(),
744 nav: Vec::new(),
745 full_name: "Test Operator".to_string(),
746 initial: "T".to_string(),
747 tz: Tz::UTC,
748 i18n: Translator::new("en"),
749 sidebar: Vec::new(),
750 crumbs: Vec::new(),
751 csrf_token: "test-csrf-token".to_string(),
752 flash: Vec::new(),
753 assets: Vec::new(),
754 }
755 }
756}
757
758fn settings_context(
765 visible: &[settings::SettingsItem],
766 admin_path: &str,
767 path: &str,
768) -> (bool, Option<String>) {
769 if path == format!("{admin_path}/settings") {
770 return (true, None);
771 }
772 let own = visible
778 .iter()
779 .filter(|i| i.link.is_none())
780 .find(|i| path == format!("{admin_path}{}", i.route()))
781 .map(|i| i.code.clone());
782 if own.is_some() {
783 return (true, own);
784 }
785 let linked = visible
786 .iter()
787 .filter_map(|i| {
788 i.link
789 .as_deref()
790 .map(|link| (format!("{admin_path}{link}"), &i.code))
791 })
792 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
793 .max_by_key(|(link, _)| link.len())
794 .map(|(_, code)| code.clone());
795 (linked.is_some(), linked)
796}
797
798#[allow(clippy::too_many_arguments)]
804#[derive(Default)]
807struct OwnMenu {
808 nav_order: Option<String>,
809 settings_order: Option<String>,
810 moves: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
811}
812
813impl OwnMenu {
814 async fn load(state: &AdminState, user_id: i64) -> Self {
815 let read = |key: String| async move {
816 laterite_auth::store::user_preference(&state.db, user_id, &key)
817 .await
818 .unwrap_or_default()
819 };
820 let nav_order = read(nav::order_preference(nav::TOP_HIERARCHY)).await;
821 let settings_order = read(nav::order_preference(settings::HIERARCHY)).await;
822 let moves = read(nav::MOVES_PREFERENCE.to_string())
823 .await
824 .map(|json| nav::operator_moves(&json))
825 .unwrap_or_default();
826 Self {
827 nav_order,
828 settings_order,
829 moves,
830 }
831 }
832}
833
834fn own_tree(
837 state: &AdminState,
838 own: &OwnMenu,
839) -> (Arc<nav::NavTree>, Arc<Vec<settings::SettingsItem>>) {
840 if own.moves.is_empty() {
841 return (Arc::clone(&state.nav), Arc::clone(&state.settings));
842 }
843 let mut items: Vec<nav::NavItem> = state.nav_items.as_ref().clone();
844 let mut settings: Vec<settings::SettingsItem> = state.settings.as_ref().clone();
845 nav::apply_moves(&mut items, &mut settings, &own.moves);
846 match nav::NavTree::build_with(items, &state.admin_path, &state.order) {
847 Ok(tree) => (Arc::new(tree), Arc::new(settings)),
848 Err(e) => {
849 tracing::warn!(error = %e, "an operator's menu moves could not be applied");
850 (Arc::clone(&state.nav), Arc::clone(&state.settings))
851 }
852 }
853}
854
855#[allow(clippy::too_many_arguments)]
856fn resolve_nav_context(
857 nav: &nav::NavTree,
858 items: &[settings::SettingsItem],
859 admin_path: &str,
860 perms: &PermissionSet,
861 own_order: Option<&str>,
862 path: &str,
863 tr: &Translator,
864 icons: icons::Icons<'_>,
865 order: &std::collections::BTreeMap<String, Vec<String>>,
866 own_settings_order: Option<&str>,
867) -> (
868 Vec<nav::NavNode>,
869 Vec<settings::CategoryView>,
870 Option<String>,
871) {
872 let mut visible_nav = nav.visible(&|code| perms.allows(code));
873 if let Some(order) = own_order {
874 nav::apply_order(&mut visible_nav, order);
875 }
876 let own_sequence = own_settings_order.map(laterite_core::arrange::parse_sequence);
877 let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
879 .into_iter()
880 .filter(|i| !i.per_operator)
881 .collect();
882 let (in_context, active) = settings_context(&visible, admin_path, path);
883 if in_context {
884 let sidebar = settings::sidebar_groups(
885 &visible,
886 admin_path,
887 active.as_deref(),
888 tr,
889 icons,
890 order.get(settings::HIERARCHY).map(Vec::as_slice),
891 own_sequence.as_deref(),
892 );
893 return (visible_nav, sidebar, Some("settings".to_string()));
894 }
895 let active_top = nav::active_top(&visible_nav, admin_path, path);
896 let sidebar = active_top
897 .map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
898 .unwrap_or_default();
899 let active_key = active_top.map(|node| node.item.key.clone());
900 (visible_nav, sidebar, active_key)
901}
902
903fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
907 preference
908 .and_then(|name| name.parse::<Tz>().ok())
909 .unwrap_or(default_tz)
910}
911
912fn zone_offered(name: &str) -> bool {
914 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
915}
916
917fn base_lang(tag: &str) -> String {
920 tag.split(['-', '_'])
921 .next()
922 .unwrap_or(tag)
923 .to_ascii_lowercase()
924}
925
926fn is_serveable(base: &str, serveable: &[String]) -> bool {
932 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
933}
934
935fn default_locale(configured: &str, serveable: &[String]) -> String {
938 let base = base_lang(configured);
939 if is_serveable(&base, serveable) {
940 base
941 } else {
942 "en".to_string()
943 }
944}
945
946fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
950 let mut out = vec!["en".to_string()];
951 out.extend(catalogs.locales());
952 out
953}
954
955fn locale_name(code: &str) -> String {
960 if code == "en" {
961 "English".to_string()
962 } else {
963 code.to_string()
964 }
965}
966
967fn parse_accept_language(header: &str) -> Vec<String> {
970 let mut items: Vec<(f32, String)> = header
971 .split(',')
972 .filter_map(|part| {
973 let mut bits = part.split(';');
974 let tag = bits.next()?.trim();
975 if tag.is_empty() || tag == "*" {
976 return None;
977 }
978 let q = bits
979 .find_map(|p| p.trim().strip_prefix("q="))
980 .and_then(|q| q.parse::<f32>().ok())
981 .unwrap_or(1.0);
982 Some((q, tag.to_string()))
983 })
984 .collect();
985 items.sort_by(|a, b| b.0.total_cmp(&a.0));
987 items.into_iter().map(|(_, tag)| tag).collect()
988}
989
990fn resolve_locale_chain(
996 preference: Option<&str>,
997 accept_language: Option<&str>,
998 default: &str,
999 serveable: &[String],
1000) -> Vec<String> {
1001 let mut chain: Vec<String> = Vec::new();
1002 let consider = |tag: &str, chain: &mut Vec<String>| {
1003 let base = base_lang(tag);
1004 if is_serveable(&base, serveable) && !chain.contains(&base) {
1005 chain.push(base);
1006 }
1007 };
1008 if let Some(pref) = preference {
1009 consider(pref, &mut chain);
1010 }
1011 if let Some(header) = accept_language {
1012 for tag in parse_accept_language(header) {
1013 consider(&tag, &mut chain);
1014 }
1015 }
1016 consider(default, &mut chain);
1017 consider("en", &mut chain);
1020 chain
1021}
1022
1023fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
1027 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
1028 let serveable = state.catalogs.locales();
1029 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
1030 Translator::with_chain(chain, state.catalogs.clone())
1031}
1032
1033#[derive(Serialize)]
1041#[non_exhaustive]
1042pub struct Resource {
1043 pub base_path: String,
1049 pub nav_label: Text,
1051 pub list: list::ListConfig,
1052 pub form: Option<form::FormConfig>,
1053 pub permission: Option<String>,
1060 pub import: Option<ImportConfig>,
1063 pub palette: Option<String>,
1066 pub relations: Vec<relation::RelationConfig>,
1069 pub menu: nav::Placement,
1072}
1073
1074#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1077#[non_exhaustive]
1078pub struct ImportConfig {
1079 #[serde(default)]
1082 pub permission: Option<String>,
1083}
1084
1085impl ImportConfig {
1086 pub fn new() -> Self {
1087 Self::default()
1088 }
1089
1090 pub fn permission(mut self, code: impl Into<String>) -> Self {
1093 self.permission = Some(code.into());
1094 self
1095 }
1096}
1097
1098#[derive(Clone, Serialize)]
1103#[non_exhaustive]
1104pub struct Permission {
1105 pub code: String,
1106 pub label: Text,
1108 pub group: Text,
1109 pub default_roles: Vec<String>,
1112 pub renamed_from: Vec<String>,
1115}
1116
1117pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
1118
1119impl Permission {
1120 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
1123 Self {
1124 code: code.into(),
1125 label: label.into(),
1126 group: group.into(),
1127 default_roles: Vec::new(),
1128 renamed_from: Vec::new(),
1129 }
1130 }
1131
1132 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
1138 where
1139 I: IntoIterator<Item = S>,
1140 S: Into<String>,
1141 {
1142 self.renamed_from = codes.into_iter().map(Into::into).collect();
1143 self
1144 }
1145
1146 pub fn roles<I, S>(mut self, roles: I) -> Self
1149 where
1150 I: IntoIterator<Item = S>,
1151 S: Into<String>,
1152 {
1153 self.default_roles = roles.into_iter().map(Into::into).collect();
1154 self
1155 }
1156}
1157
1158pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
1164 let permissions: Vec<Permission> = builtin_permissions()
1165 .into_iter()
1166 .chain(contributed.iter().cloned())
1167 .collect();
1168 let mut out: Vec<(String, String)> = Vec::new();
1169 for permission in &permissions {
1170 for old in &permission.renamed_from {
1171 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
1172 panic!(
1173 "permissions `{taken}` and `{}` both claim the old code `{old}`",
1174 permission.code
1175 );
1176 }
1177 if permissions.iter().any(|p| &p.code == old) {
1178 panic!(
1179 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
1180 permission.code
1181 );
1182 }
1183 out.push((old.clone(), permission.code.clone()));
1184 }
1185 }
1186 out
1187}
1188
1189pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
1195 let permissions: Vec<Permission> = builtin_permissions()
1198 .into_iter()
1199 .chain(contributed.iter().cloned())
1200 .collect();
1201 let held = |role: &str| -> Vec<String> {
1202 permissions
1203 .iter()
1204 .filter(|p| {
1205 if p.default_roles.is_empty() {
1206 role == ROLE_ADMIN
1207 } else {
1208 p.default_roles.iter().any(|r| r == role)
1209 }
1210 })
1211 .map(|p| p.code.clone())
1212 .collect()
1213 };
1214 vec![
1215 laterite_auth::store::SystemRole {
1216 code: ROLE_ADMIN,
1217 name: "Administrator",
1218 description: "Administers the panel itself: operators, roles and settings.",
1219 permissions: held(ROLE_ADMIN),
1220 },
1221 laterite_auth::store::SystemRole {
1222 code: ROLE_EDITOR,
1223 name: "Editor",
1224 description: "Works with the content the application declares.",
1225 permissions: held(ROLE_EDITOR),
1226 },
1227 ]
1228}
1229
1230impl Resource {
1231 pub fn new(
1236 base_path: impl Into<String>,
1237 nav_label: impl Into<Text>,
1238 list: list::ListConfig,
1239 ) -> Self {
1240 Self {
1241 base_path: base_path.into(),
1242 nav_label: nav_label.into(),
1243 list,
1244 form: None,
1245 permission: None,
1246 import: None,
1247 palette: None,
1248 relations: Vec::new(),
1249 menu: nav::Placement::default(),
1250 }
1251 }
1252
1253 pub fn palette(mut self, source: impl Into<String>) -> Self {
1256 self.palette = Some(source.into());
1257 self
1258 }
1259
1260 pub fn menu(mut self, placement: nav::Placement) -> Self {
1262 self.menu = placement;
1263 self
1264 }
1265
1266 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1268 self.relations.push(relation);
1269 self
1270 }
1271
1272 pub fn import(mut self, import: ImportConfig) -> Self {
1274 self.import = Some(import);
1275 self
1276 }
1277
1278 pub fn form(mut self, form: form::FormConfig) -> Self {
1281 self.form = Some(form);
1282 self
1283 }
1284
1285 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1287 self.permission = Some(permission.into());
1288 self
1289 }
1290}
1291
1292struct SystemRoleGuard;
1298
1299#[laterite_core::strata::async_trait]
1300impl laterite_core::ModelListener for SystemRoleGuard {
1301 async fn before_delete(
1302 &self,
1303 cx: &mut laterite_core::SaveCx<'_>,
1304 rec: &laterite_core::Record,
1305 ) -> Result<(), Text> {
1306 let Some(id) = rec.id() else {
1307 return Ok(());
1308 };
1309 let backend = cx.backend();
1311 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1312 Ok(true) => Err(t!(
1313 "The framework maintains this role; it cannot be deleted."
1314 )),
1315 Err(_) => Err(t!(
1317 "Could not check whether this role is maintained by the framework."
1318 )),
1319 Ok(false) => Ok(()),
1320 }
1321 }
1322}
1323
1324fn builtin_permissions() -> Vec<Permission> {
1327 let mut out = vec![
1330 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1331 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1332 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1333 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1334 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1335 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1336 Permission::new(
1337 "backend.view_request_log",
1338 "View the request log",
1339 "Backend",
1340 ),
1341 ];
1342 out.extend(medialib::permissions());
1343 out
1344}
1345
1346pub fn descriptor_sources() -> Vec<String> {
1352 let mut out = Vec::new();
1353 out.extend(laterite_core::collect_sources(&builtin_resources()));
1354 out.extend(laterite_core::collect_sources(&builtin_settings()));
1355 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1356 out.sort();
1357 out.dedup();
1358 out
1359}
1360
1361pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1376 builtin_modules().iter().map(|m| m.migrations()).collect()
1377}
1378
1379pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1383 vec![
1384 Box::new(laterite_auth::AuthModule),
1385 Box::new(settings::SettingsModule),
1386 Box::new(requestlog::RequestLogModule),
1387 Box::new(plugins::PluginsModule),
1388 Box::new(laterite_media::MediaModule),
1389 Box::new(medialib::MediaLibraryModule),
1390 Box::new(import::ImportModule),
1391 ]
1392}
1393
1394#[derive(Default)]
1418pub struct Contributions {
1419 pub resources: Vec<Resource>,
1421 pub settings: Vec<settings::SettingsItem>,
1423 pub permissions: Vec<Permission>,
1425 pub picker_sources: Vec<picker::PickerSourceReg>,
1427 pub persisters: Vec<persist::PersisterReg>,
1429 pub listeners: Vec<laterite_core::ModelListenerReg>,
1431 pub field_types: Vec<field::FieldTypeReg>,
1435 pub column_types: Vec<list::ColumnTypeReg>,
1436 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1439 pub screens: Vec<routes::ScreenReg>,
1441 pub public_routes: Vec<routes::PublicRouteReg>,
1443 pub nav: Vec<nav::NavItem>,
1446 pub resource_hooks: Vec<ResourceHook>,
1449 pub plugin_defined: Arc<laterite_core::Registry>,
1454}
1455
1456pub fn router(
1457 auth: AuthService,
1458 db: Db,
1459 contributions: Contributions,
1460 config: AdminConfig,
1461 catalogs: Arc<CatalogStore>,
1462) -> Router {
1463 let Contributions {
1464 resources: app_resources,
1465 settings: app_settings,
1466 permissions: app_permissions,
1467 picker_sources: app_picker_sources,
1468 persisters: app_persisters,
1469 listeners: app_listeners,
1470 field_types: app_field_types,
1471 column_types: app_column_types,
1472 icons: app_icons,
1473 screens: app_screens,
1474 public_routes: app_public,
1475 nav: app_nav,
1476 resource_hooks,
1477 plugin_defined,
1478 } = contributions;
1479 let mut app_screens = app_screens;
1482 app_screens.push(medialib::screen());
1483 let mut app_public = app_public;
1484 app_public.push(medialib::serve_route());
1485 let admin_path = normalize_path(&config.path);
1486
1487 let mut resources = builtin_resources();
1488 let mut app_resources = app_resources;
1489 for hook in resource_hooks {
1492 let found = resources
1493 .iter_mut()
1494 .chain(app_resources.iter_mut())
1495 .find(|r| r.base_path == hook.target);
1496 match found {
1497 Some(resource) => {
1498 let changed = hook.apply(std::mem::replace(
1499 resource,
1500 Resource::new("", "", list::ListConfig::default()),
1501 ));
1502 *resource = changed;
1503 }
1504 None => panic!(
1505 "a resource hook targets `{}`, which no module declared",
1506 hook.target
1507 ),
1508 }
1509 }
1510 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1514 prefix_resource(&admin_path, resource);
1515 }
1516 for resource in app_resources.iter_mut() {
1520 if let Some(import) = &resource.import {
1521 let permission = import
1522 .permission
1523 .clone()
1524 .or_else(|| resource.permission.clone())
1525 .unwrap_or_else(|| {
1526 panic!(
1527 "admin resource `{}` declares `import` but no permission; importing \
1528 writes records, so it must be gated",
1529 resource.base_path
1530 )
1531 });
1532 resource.list.import = Some(routes::feature_link(
1533 &resource.base_path,
1534 "import",
1535 &permission,
1536 ));
1537 }
1538 }
1539
1540 let mut settings = builtin_settings();
1541 settings.extend(app_settings);
1542 let mut app_listeners = app_listeners;
1545 app_listeners.insert(
1546 0,
1547 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1548 );
1549
1550 let mut permissions = builtin_permissions();
1551 permissions.extend(app_permissions);
1552 let permission_codes: std::collections::HashSet<String> =
1554 permissions.iter().map(|p| p.code.clone()).collect();
1555
1556 let mut nav_items = vec![
1562 nav::NavItem::new("dashboard", "Dashboard", "")
1563 .icon("layout-dashboard")
1564 .slot(laterite_core::arrange::slot::HOME),
1565 nav::NavItem::new("settings", "Settings", "/settings")
1566 .icon("settings")
1567 .slot(laterite_core::arrange::slot::SYSTEM),
1568 ];
1569 nav_items.extend(app_nav);
1570 for resource in &app_resources {
1571 if resource.menu.hidden {
1572 continue;
1573 }
1574 let unprefixed = resource
1575 .base_path
1576 .strip_prefix(admin_path.as_str())
1577 .unwrap_or(&resource.base_path)
1578 .to_string();
1579 let mut item = nav::NavItem::new(
1580 format!("resource:{unprefixed}"),
1581 resource.nav_label.clone(),
1582 unprefixed,
1583 )
1584 .slot(resource.menu.slot)
1585 .order(resource.menu.order);
1586 item.icon = resource.menu.icon.clone();
1587 item.permission = resource.permission.clone();
1588 item.under = resource.menu.under.clone();
1589 item.after = resource.menu.after.clone();
1590 item.before = resource.menu.before.clone();
1591 nav_items.push(item);
1592 }
1593 for reg in &app_screens {
1594 let Some(label) = ®.nav_label else {
1595 continue;
1596 };
1597 if reg.menu.hidden {
1598 continue;
1599 }
1600 let mut item = nav::NavItem::new(
1601 format!("screen:{}", reg.base_path),
1602 label.clone(),
1603 reg.base_path.clone(),
1604 )
1605 .permission(reg.permission.clone())
1606 .slot(reg.menu.slot)
1607 .order(reg.menu.order);
1608 item.icon = reg.menu.icon.clone();
1609 item.under = reg.menu.under.clone();
1610 item.after = reg.menu.after.clone();
1611 item.before = reg.menu.before.clone();
1612 nav_items.push(item);
1613 }
1614 nav::apply_moves(&mut nav_items, &mut settings, &config.menu);
1617 let nav = nav::NavTree::build_with(nav_items.clone(), &admin_path, &config.order)
1618 .unwrap_or_else(|e| panic!("admin menu: {e}"));
1619 nav.log_arrangement();
1620 settings::log_arrangement(
1621 &settings,
1622 config.order.get(settings::HIERARCHY).map(Vec::as_slice),
1623 );
1624 let palette_sources: Vec<palette::RecordSource> = app_resources
1627 .iter()
1628 .filter_map(|r| {
1629 r.palette.as_ref().map(|source| palette::RecordSource {
1630 label: r.nav_label.clone(),
1631 source: source.clone(),
1632 base_path: r.base_path.clone(),
1633 permission: r.permission.clone(),
1634 })
1635 })
1636 .collect();
1637 resources.extend(app_resources);
1638
1639 let app_name = if config.app_name.trim().is_empty() {
1640 "Laterite".to_string()
1641 } else {
1642 config.app_name.clone()
1643 };
1644
1645 let mut pickers = picker::PickerRegistry::new();
1648 for reg in app_picker_sources {
1649 let name = reg.name.clone();
1650 if !field::is_name(&name, true) {
1651 panic!("invalid picker source name `{name}`");
1652 }
1653 if pickers.insert(name.clone(), reg).is_some() {
1654 panic!("duplicate picker source `{name}`");
1655 }
1656 }
1657
1658 for source in &palette_sources {
1662 if !pickers.contains_key(&source.source) {
1663 panic!(
1664 "admin resource `{}` names palette source `{}`, which no module registered",
1665 source.base_path, source.source
1666 );
1667 }
1668 }
1669 let pickers = Arc::new(pickers);
1670 let mut field_types = field::builtin_registry();
1671 field_types.insert(
1672 "reference".to_string(),
1673 Arc::new(field::RefPickerField::new(pickers.clone())),
1674 );
1675
1676 let mut persisters = persist::PersisterRegistry::new();
1679 for reg in app_persisters {
1680 let name = reg.name.clone();
1681 if !field::is_name(&name, true) {
1682 panic!("invalid persister name `{name}`");
1683 }
1684 if persisters.insert(name.clone(), reg.persister).is_some() {
1685 panic!("duplicate persister `{name}`");
1686 }
1687 }
1688
1689 for reg in app_field_types {
1695 let name = reg.name().to_string();
1696 if !field::is_name(&name, true) {
1697 panic!("invalid field type name `{name}`");
1698 }
1699 if field_types.insert(name.clone(), reg.field_type).is_some() {
1700 panic!("duplicate field type `{name}`");
1701 }
1702 }
1703
1704 let mut icon_set = laterite_core::icons::IconSet::new();
1708 for (owner, reg) in app_icons {
1709 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1710 panic!("module `{owner}` contributed an invalid icon: {e}");
1711 }
1712 }
1713
1714 let mut assets = builtin_assets();
1717 assets.insert(
1718 ICON_SPRITE,
1719 AdminAsset {
1720 mime: "image/svg+xml",
1721 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1722 },
1723 );
1724
1725 for item in &settings {
1729 icons::require(
1730 &icon_set,
1731 &format!("settings item `{}`", item.code),
1732 item.icon.as_deref(),
1733 );
1734 }
1735 for node in nav
1736 .top
1737 .iter()
1738 .flat_map(|n| std::iter::once(n).chain(n.children.iter()))
1739 {
1740 icons::require(
1741 &icon_set,
1742 &format!("menu entry `{}`", node.item.key),
1743 node.item.icon.as_deref(),
1744 );
1745 }
1746
1747 let mut column_types = list::builtin_column_registry();
1751 for reg in app_column_types {
1752 let name = reg.name().to_string();
1753 if !field::is_name(&name, true) {
1754 panic!("invalid column type name `{name}`");
1755 }
1756 if column_types.insert(name.clone(), reg.column_type).is_some() {
1757 panic!("duplicate column type `{name}`");
1758 }
1759 }
1760
1761 let settings = Arc::new(settings);
1762 let asset_urls_arc = Arc::new(asset_urls(&assets));
1764 let sprite_url: Arc<str> = Arc::from(
1765 asset_urls_arc
1766 .get(ICON_SPRITE)
1767 .map(|p| format!("{admin_path}/assets/{p}"))
1768 .unwrap_or_default()
1769 .as_str(),
1770 );
1771 let icons_arc = Arc::new(icon_set);
1772 let state = AdminState {
1773 auth,
1774 db,
1775 nav: Arc::new(nav),
1776 nav_items: Arc::new(nav_items),
1777 settings: settings.clone(),
1778 permissions: Arc::new(permissions),
1779 admin_path: Arc::from(admin_path.as_str()),
1780 secure_cookie: config.secure_cookie,
1781 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1782 icons: icons_arc.clone(),
1783 sprite_url: sprite_url.clone(),
1784 origin: Arc::from(config.origin.trim_end_matches('/')),
1785 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1786 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1787 catalogs,
1788 app_name,
1789 brand_cache: Arc::new(RwLock::new(None)),
1790 field_types: Arc::new(field_types),
1791 column_types: Arc::new(column_types),
1792 plugin_defined,
1793 storage_dir: Arc::from(config.storage_dir.trim_end_matches('/')),
1794 signing_key: Arc::new(config.secret.clone()),
1795 order: config.order.clone(),
1796 cookie_path: if app_public.iter().any(|r| r.is_site()) {
1797 Arc::from("/")
1798 } else {
1799 Arc::from(admin_path.as_str())
1800 },
1801 assets: Arc::new(assets),
1802 asset_urls: asset_urls_arc.clone(),
1803 pickers,
1804 palette: Arc::new(palette_sources),
1805 overrides: Arc::new(field::NoOverrides),
1806 };
1807
1808 let mut protected = Router::new().route(&admin_path, get(dashboard));
1809 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
1813 for resource in &resources {
1814 let (mounted, prepared) = mount_resource(
1815 resource,
1816 &state.field_types,
1817 &state.column_types,
1818 &permission_codes,
1819 &persisters,
1820 &app_listeners,
1821 );
1822 protected = protected.merge(mounted);
1823 if let Some(prepared) = prepared {
1824 let key = resource
1825 .base_path
1826 .strip_prefix(admin_path.as_str())
1827 .unwrap_or(&resource.base_path);
1828 forms.insert(key.to_string(), prepared);
1829 }
1830 }
1831 let forms: form::Forms = Arc::new(forms);
1832 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
1836 .plugin_defined
1837 .items::<laterite_media::DiskReg>()
1838 .into_iter()
1839 .find(|d| d.name == import::DISK)
1840 .map(|d| d.driver.clone())
1841 .unwrap_or_else(|| {
1842 Arc::new(laterite_media::LocalDisk::new(format!(
1843 "{}/{}",
1844 config.storage_dir.trim_end_matches('/'),
1845 import::DISK
1846 )))
1847 });
1848 for resource in &resources {
1849 let Some(link) = &resource.list.import else {
1850 continue;
1851 };
1852 let key = resource
1853 .base_path
1854 .strip_prefix(admin_path.as_str())
1855 .unwrap_or(&resource.base_path)
1856 .to_string();
1857 let prepared = forms.get(&key).unwrap_or_else(|| {
1858 panic!(
1859 "admin resource `{}` declares `import` but has no form to import through",
1860 resource.base_path
1861 )
1862 });
1863 let feature = import::ImportFeature {
1864 disk: private_disk.clone(),
1865 };
1866 let base = link.href.clone();
1867 let ctx = routes::RouteCtx::new(
1868 state.db.clone(),
1869 state.auth.clone(),
1870 state.field_types.clone(),
1871 state.overrides.clone(),
1872 state.signing_key.clone(),
1873 &state.storage_dir,
1874 &base,
1875 &admin_path,
1876 &state.origin,
1877 state.plugin_defined.clone(),
1878 state.auth.events().clone(),
1879 forms.clone(),
1880 );
1881 let info = routes::ResourceInfo::new(
1882 key,
1883 resource.base_path.clone(),
1884 resource.nav_label.clone(),
1885 link.permission.clone(),
1886 form::FormHandle::new(state.db.clone(), prepared.clone()),
1887 );
1888 protected = protected.merge(guard_with_permission(
1889 Router::new()
1890 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
1891 &link.permission,
1892 ));
1893 }
1894 for reg in &app_screens {
1898 let base = format!("{admin_path}{}", reg.base_path);
1899 let ctx = routes::RouteCtx::new(
1900 state.db.clone(),
1901 state.auth.clone(),
1902 state.field_types.clone(),
1903 state.overrides.clone(),
1904 state.signing_key.clone(),
1905 &state.storage_dir,
1906 &base,
1907 &admin_path,
1908 &state.origin,
1909 state.plugin_defined.clone(),
1910 state.auth.events().clone(),
1911 forms.clone(),
1912 );
1913 protected = protected.merge(guard_with_permission(
1914 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1915 ®.permission,
1916 ));
1917 }
1918
1919 protected = protected.merge(guard_with_permission(
1922 Router::new()
1923 .route(
1924 &format!("{admin_path}/roles/new"),
1925 get(roles::new_form).post(roles::create),
1926 )
1927 .route(
1928 &format!("{admin_path}/roles/{{id}}/edit"),
1929 get(roles::edit_form).post(roles::update),
1930 ),
1931 "backend.manage_roles",
1932 ));
1933 protected = protected.merge(guard_with_permission(
1936 Router::new()
1937 .route(
1938 &format!("{admin_path}/users/new"),
1939 get(users::new_form).post(users::create),
1940 )
1941 .route(
1942 &format!("{admin_path}/users/{{id}}/edit"),
1943 get(users::edit_form).post(users::update),
1944 )
1945 .route(
1946 &format!("{admin_path}/users/{{id}}/active"),
1947 post(users::set_active),
1948 )
1949 .route(
1950 &format!("{admin_path}/users/{{id}}/password"),
1951 post(users::reset_password),
1952 )
1953 .route(
1954 &format!("{admin_path}/users/{{id}}/unlock"),
1955 post(users::unlock),
1956 ),
1957 "backend.manage_users",
1958 ));
1959 protected = protected.merge(guard_with_permission(
1962 Router::new()
1963 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1964 .route(
1965 &format!("{admin_path}/plugins/toggle"),
1966 post(plugins::toggle),
1967 ),
1968 plugins::MANAGE_PERMISSION,
1969 ));
1970 protected = protected
1971 .route(&format!("{admin_path}/settings"), get(settings_index))
1972 .route(
1973 &format!("{admin_path}/preferences"),
1974 get(preferences_form).post(preferences_update),
1975 )
1976 .route(
1977 &format!("{admin_path}/preferences/password"),
1978 post(password_update),
1979 )
1980 .route(
1981 &format!("{admin_path}/preferences/menu"),
1982 post(menu_order_update),
1983 )
1984 .route(
1985 &format!("{admin_path}/preferences/menu/moves"),
1986 post(menu_moves_update),
1987 )
1988 .route(&format!("{admin_path}/palette"), get(palette::palette))
1989 .route(
1990 &format!("{admin_path}/preferences/sessions/revoke"),
1991 post(session_revoke),
1992 )
1993 .route(
1994 &format!("{admin_path}/preferences/sessions/revoke-others"),
1995 post(session_revoke_others),
1996 )
1997 .route(
2000 &format!("{admin_path}/pickers/{{source}}/search"),
2001 any(picker::search),
2002 )
2003 .route(
2004 &format!("{admin_path}/pickers/{{source}}/resolve"),
2005 any(picker::resolve),
2006 )
2007 .route(&format!("{admin_path}/logout"), post(logout));
2008
2009 for item in settings.iter().filter(|i| i.link.is_none()) {
2013 let path = format!("{admin_path}{}", item.route());
2014 let code = item.code.clone();
2015 let edit_code = code.clone();
2016 protected = protected.route(
2017 &path,
2018 get(
2019 move |State(state): State<AdminState>,
2020 Extension(shell): Extension<Shell>,
2021 Extension(user): Extension<AuthenticatedUser>| {
2022 let code = edit_code.clone();
2023 async move { settings_edit(state, shell, user, code).await }
2024 },
2025 )
2026 .post(
2027 move |State(state): State<AdminState>,
2028 Extension(shell): Extension<Shell>,
2029 Extension(user): Extension<AuthenticatedUser>,
2030 Extension(session): Extension<session::SessionHandle>,
2031 Form(data): Form<HashMap<String, String>>| {
2032 let code = code.clone();
2033 async move { settings_update(state, shell, user, session, code, data).await }
2034 },
2035 ),
2036 );
2037 }
2038
2039 let app = protected
2040 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
2041 .route(
2045 &format!("{admin_path}/login"),
2046 get(login_form).post(login_submit),
2047 )
2048 .route(
2049 &format!("{admin_path}/setup"),
2050 get(setup_form).post(setup_submit),
2051 )
2052 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
2053 .merge(mount_public(
2057 &app_public,
2058 &state.db,
2059 &state.auth,
2060 &state.field_types,
2061 &state.overrides,
2062 &state.signing_key,
2063 &state.storage_dir,
2064 &admin_path,
2065 &state.origin,
2066 &state.plugin_defined,
2067 state.auth.events(),
2068 &forms,
2069 ));
2070 let app = match app_public.iter().find(|r| r.is_site()) {
2073 Some(site) => {
2074 let ctx = routes::RouteCtx::new(
2075 state.db.clone(),
2076 state.auth.clone(),
2077 state.field_types.clone(),
2078 state.overrides.clone(),
2079 state.signing_key.clone(),
2080 &state.storage_dir,
2081 "/",
2082 &admin_path,
2083 &state.origin,
2084 state.plugin_defined.clone(),
2085 state.auth.events().clone(),
2086 forms.clone(),
2087 );
2088 app.fallback_service(site.route.mount(&ctx))
2089 }
2090 None => app.fallback(not_found_fallback),
2091 };
2092 app
2093 .layer(middleware::from_fn_with_state(
2096 state.clone(),
2097 enforce_origin,
2098 ))
2099 .layer(middleware::from_fn_with_state(
2102 state.clone(),
2103 capture_client,
2104 ))
2105 .layer(
2110 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2111 header::CACHE_CONTROL,
2112 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
2113 ),
2114 )
2115 .layer(
2119 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2120 header::VARY,
2121 axum::http::HeaderValue::from_static("HX-Request"),
2122 ),
2123 )
2124 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
2125 handle_panic,
2126 ))
2127 .with_state(state)
2128}
2129
2130async fn not_found_fallback(
2133 State(state): State<AdminState>,
2134 method: axum::http::Method,
2135 uri: axum::http::Uri,
2136) -> AdminError {
2137 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
2138 AdminError::NotFound
2139}
2140
2141async fn not_found_plain() -> AdminError {
2143 AdminError::NotFound
2144}
2145
2146fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
2148 let cause = err
2149 .downcast_ref::<&str>()
2150 .map(|s| s.to_string())
2151 .or_else(|| err.downcast_ref::<String>().cloned())
2152 .unwrap_or_else(|| "unknown panic".to_string());
2153 tracing::error!(panic = cause, "admin handler panicked");
2154 error::masked_500()
2155}
2156
2157pub fn normalize_path(path: &str) -> String {
2162 let trimmed = path.trim().trim_matches('/');
2163 if trimmed.is_empty() {
2164 "/admin".to_string()
2165 } else {
2166 format!("/{trimmed}")
2167 }
2168}
2169
2170#[allow(clippy::too_many_arguments)]
2174fn mount_public(
2175 regs: &[routes::PublicRouteReg],
2176 db: &Db,
2177 auth: &AuthService,
2178 field_types: &Arc<field::FieldRegistry>,
2179 overrides: &Arc<dyn field::OverrideResolver>,
2180 signing_key: &Arc<Vec<u8>>,
2181 storage_dir: &str,
2182 admin_path: &str,
2183 origin: &str,
2184 plugin_defined: &Arc<laterite_core::Registry>,
2185 events: &laterite_core::Events,
2186 forms: &form::Forms,
2187) -> Router<AdminState> {
2188 let mut router = Router::new();
2189 for reg in regs.iter().filter(|r| !r.is_site()) {
2191 let ctx = routes::RouteCtx::new(
2192 db.clone(),
2193 auth.clone(),
2194 field_types.clone(),
2195 overrides.clone(),
2196 signing_key.clone(),
2197 storage_dir,
2198 ®.path,
2199 admin_path,
2200 origin,
2201 plugin_defined.clone(),
2202 events.clone(),
2203 forms.clone(),
2204 );
2205 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
2210 }
2211 router
2212}
2213
2214pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
2217 let old = resource.base_path.clone();
2218 resource.base_path = base.to_string();
2219 if let Some(edit_base) = &mut resource.list.edit_base {
2220 if edit_base == &old {
2221 *edit_base = base.to_string();
2222 }
2223 }
2224 if let Some(form) = &mut resource.form {
2225 if form.base_path == old {
2226 form.base_path = base.to_string();
2227 }
2228 }
2229}
2230
2231fn prefix_resource(admin_path: &str, resource: &mut Resource) {
2232 resource.base_path = format!("{admin_path}{}", resource.base_path);
2233 if let Some(edit_base) = &mut resource.list.edit_base {
2234 *edit_base = format!("{admin_path}{edit_base}");
2235 }
2236 if let Some(form) = &mut resource.form {
2237 form.base_path = format!("{admin_path}{}", form.base_path);
2238 }
2239}
2240
2241pub(crate) struct AdminAsset {
2243 pub mime: &'static str,
2244 pub bytes: std::borrow::Cow<'static, [u8]>,
2248}
2249
2250pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2253
2254pub(crate) type AssetUrls = HashMap<&'static str, String>;
2258
2259pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2261 registry
2262 .iter()
2263 .map(|(&key, asset)| {
2264 (
2265 key,
2266 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2267 )
2268 })
2269 .collect()
2270}
2271
2272pub(crate) fn page_assets(
2279 keys: &[&str],
2280 base: &str,
2281 registry: &AssetRegistry,
2282 urls: &AssetUrls,
2283) -> Vec<PageAsset> {
2284 let mut seen = std::collections::HashSet::new();
2285 let mut out = Vec::new();
2286 for &key in keys {
2287 if !seen.insert(key) {
2288 continue;
2289 }
2290 match (registry.get(key), urls.get(key)) {
2291 (Some(asset), Some(path)) => out.push(PageAsset {
2292 url: format!("{base}/assets/{path}"),
2293 css: asset.mime.contains("css"),
2294 }),
2295 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2296 }
2297 }
2298 out
2299}
2300
2301pub(crate) fn builtin_assets() -> AssetRegistry {
2310 let font = |bytes: &'static [u8]| AdminAsset {
2311 mime: "font/woff2",
2312 bytes: std::borrow::Cow::Borrowed(bytes),
2313 };
2314 HashMap::from([
2315 (
2316 "laterite.css",
2317 AdminAsset {
2318 mime: "text/css; charset=utf-8",
2319 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2320 },
2321 ),
2322 (
2323 "laterite.js",
2324 AdminAsset {
2325 mime: "text/javascript; charset=utf-8",
2326 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2327 },
2328 ),
2329 (
2330 "vendor/htmx.min.js",
2331 AdminAsset {
2332 mime: "text/javascript; charset=utf-8",
2333 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2334 },
2335 ),
2336 (
2337 "fields/ref-picker.js",
2338 AdminAsset {
2339 mime: "text/javascript; charset=utf-8",
2340 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
2341 },
2342 ),
2343 (
2344 "fields/ref-picker.css",
2345 AdminAsset {
2346 mime: "text/css; charset=utf-8",
2347 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2348 "../assets/fields/ref-picker.css"
2349 )),
2350 },
2351 ),
2352 (
2353 "mark.svg",
2354 AdminAsset {
2355 mime: "image/svg+xml",
2356 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
2357 },
2358 ),
2359 (
2360 "mark.png",
2361 AdminAsset {
2362 mime: "image/png",
2363 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
2364 },
2365 ),
2366 (
2367 "fonts/space-grotesk-500.woff2",
2368 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
2369 ),
2370 (
2371 "fonts/space-grotesk-600.woff2",
2372 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
2373 ),
2374 (
2375 "fonts/space-grotesk-700.woff2",
2376 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
2377 ),
2378 (
2379 "fonts/ibm-plex-sans-400.woff2",
2380 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
2381 ),
2382 (
2383 "fonts/ibm-plex-sans-600.woff2",
2384 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
2385 ),
2386 (
2387 "fonts/ibm-plex-mono-400.woff2",
2388 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
2389 ),
2390 (
2391 "fonts/ibm-plex-mono-600.woff2",
2392 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
2393 ),
2394 ])
2395}
2396
2397async fn serve_asset(
2399 State(state): State<AdminState>,
2400 headers: HeaderMap,
2401 Path(path): Path<String>,
2402) -> Response {
2403 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
2407 let Some(asset) = state.assets.get(key.as_str()) else {
2408 return not_found();
2409 };
2410 let policy = if fingerprinted {
2411 http_cache::IMMUTABLE
2412 } else {
2413 http_cache::REVALIDATE
2414 };
2415 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
2416}
2417
2418fn mount_resource(
2423 resource: &Resource,
2424 field_types: &field::FieldRegistry,
2425 column_types: &list::ColumnRegistry,
2426 permission_codes: &std::collections::HashSet<String>,
2427 persisters: &persist::PersisterRegistry,
2428 listeners: &[laterite_core::ModelListenerReg],
2429) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2430 list::check(&resource.list, column_types, permission_codes)
2434 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2435 if resource.form.is_some() && resource.permission.is_none() {
2440 panic!(
2441 "admin resource `{}` has a create/edit form but no permission; a write \
2442 resource must declare a permission",
2443 resource.base_path
2444 );
2445 }
2446 let base = resource.base_path.clone();
2447 let list_path = base.clone();
2448 let mut list_cfg = resource.list.clone();
2449 if resource.list.columns.iter().any(|c| c.editable) {
2452 let Some(form_cfg) = resource.form.clone() else {
2453 panic!(
2454 "admin resource `{}` has an editable column but no form to edit through",
2455 resource.base_path
2456 );
2457 };
2458 for column in resource.list.columns.iter().filter(|c| c.editable) {
2459 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2460 panic!(
2461 "admin resource `{}`: column `{}` is editable but the form has no such field",
2462 resource.base_path, column.field
2463 );
2464 }
2465 }
2466 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2467 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2468 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2469 }
2470 let cell_cfg = list_cfg.clone();
2471 let mut router = Router::new().route(
2472 &base,
2473 get(
2474 move |State(state): State<AdminState>,
2475 Extension(shell): Extension<Shell>,
2476 Query(params): Query<list::ListParams>,
2477 Query(raw): Query<std::collections::HashMap<String, String>>,
2478 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2479 Extension(user): Extension<AuthenticatedUser>,
2480 Extension(session): Extension<session::SessionHandle>,
2481 headers: axum::http::HeaderMap| {
2482 let cfg = list_cfg.clone();
2483 let path = list_path.clone();
2484 async move {
2485 list::handle_remembering(
2486 &state,
2487 &cfg,
2488 &path,
2489 params,
2490 &raw,
2491 &user,
2492 shell,
2493 &headers,
2494 query_string.as_deref(),
2495 Some(&session),
2496 )
2497 .await
2498 }
2499 },
2500 ),
2501 );
2502
2503 if cell_cfg.editor.is_some() {
2504 let (cfg, list_path) = (cell_cfg, base.clone());
2505 router = router.route(
2506 &format!("{base}/{{id}}/cell"),
2507 post(
2508 move |State(state): State<AdminState>,
2509 Extension(shell): Extension<Shell>,
2510 Extension(user): Extension<AuthenticatedUser>,
2511 Extension(session): Extension<session::SessionHandle>,
2512 Path(id): Path<String>,
2513 headers: axum::http::HeaderMap,
2514 Form(data): Form<HashMap<String, String>>| {
2515 let (cfg, path) = (cfg.clone(), list_path.clone());
2516 async move {
2517 list::cell_handler(
2518 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2519 )
2520 .await
2521 }
2522 },
2523 ),
2524 );
2525 }
2526
2527 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2530 if resource.list.exportable {
2531 router = router.route(
2532 &format!("{base}/export"),
2533 get(
2534 move |state: State<AdminState>,
2535 Query(raw): Query<std::collections::HashMap<String, String>>,
2536 Extension(user): Extension<AuthenticatedUser>,
2537 Extension(shell): Extension<Shell>,
2538 Extension(session): Extension<session::SessionHandle>| {
2539 let cfg = export_cfg.clone();
2540 let path = export_path.clone();
2541 async move {
2542 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2543 }
2544 },
2545 ),
2546 );
2547 }
2548
2549 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2550 router = router.route(
2551 &format!("{base}/columns"),
2552 post(
2553 move |State(state): State<AdminState>,
2554 Extension(user): Extension<AuthenticatedUser>,
2555 Extension(session): Extension<session::SessionHandle>,
2556 headers: axum::http::HeaderMap,
2557 Form(pairs): Form<Vec<(String, String)>>| {
2558 let cfg = columns_cfg.clone();
2559 let path = columns_path.clone();
2560 async move {
2561 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2562 }
2563 },
2564 ),
2565 );
2566
2567 if resource.list.deletable {
2568 let persister: Arc<dyn persist::Persister> =
2572 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
2573 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
2574 panic!(
2575 "admin resource `{}`: unregistered persister `{key}`",
2576 resource.base_path
2577 )
2578 }),
2579 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
2580 };
2581 let ctx = Arc::new(bulk::BulkContext {
2582 base_path: resource.base_path.clone(),
2583 entity: resource.list.entity.clone(),
2584 persister,
2585 listeners: listeners
2586 .iter()
2587 .filter(|reg| reg.matches(&resource.list.entity))
2588 .map(|reg| reg.listener.clone())
2589 .collect(),
2590 });
2591 router =
2592 router.route(
2593 &format!("{base}/delete"),
2594 post(
2595 move |state: State<AdminState>,
2596 user: Extension<AuthenticatedUser>,
2597 session: Extension<session::SessionHandle>,
2598 headers: axum::http::HeaderMap,
2599 form: Form<Vec<(String, String)>>| {
2600 let ctx = ctx.clone();
2601 async move {
2602 bulk::delete_handler(state, user, session, ctx, headers, form).await
2603 }
2604 },
2605 ),
2606 );
2607 }
2608
2609 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
2612 let tree = resource.list.tree.clone().unwrap_or_default();
2613 let mut move_cfg = form::FormConfig::new(
2614 resource.list.entity.clone(),
2615 laterite_core::t!("Move to"),
2616 base.clone(),
2617 resource.list.id_field.clone(),
2618 vec![form::FormField::reference(
2619 &tree.parent_key,
2620 laterite_core::t!("New parent"),
2621 &source,
2622 )
2623 .help(laterite_core::t!("Leave empty for the top level."))],
2624 );
2625 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
2626 move_cfg = move_cfg.persist(persist);
2627 }
2628 move_cfg.done_path = Some(base.clone());
2629 let move_form = Arc::new(
2630 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
2631 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
2632 );
2633 let (cfg, list_path) = (resource.list.clone(), base.clone());
2634 let (form_get, form_post) = (move_form.clone(), move_form);
2635 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
2636 router = router.route(
2637 &format!("{base}/{{id}}/move"),
2638 get(
2639 move |State(state): State<AdminState>,
2640 Extension(shell): Extension<Shell>,
2641 Path(id): Path<String>| {
2642 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
2643 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
2644 },
2645 )
2646 .post(
2647 move |State(state): State<AdminState>,
2648 Extension(shell): Extension<Shell>,
2649 Extension(user): Extension<AuthenticatedUser>,
2650 Extension(session): Extension<session::SessionHandle>,
2651 Path(id): Path<String>,
2652 headers: axum::http::HeaderMap,
2653 Form(data): Form<HashMap<String, String>>| {
2654 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
2655 async move {
2656 list::move_record(
2657 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
2658 )
2659 .await
2660 }
2661 },
2662 ),
2663 );
2664 }
2665
2666 if resource.list.reorder.is_some() {
2667 let (cfg, list_path) = (resource.list.clone(), base.clone());
2668 router = router.route(
2669 &format!("{base}/reorder"),
2670 post(
2671 move |State(state): State<AdminState>,
2672 Extension(shell): Extension<Shell>,
2673 Extension(user): Extension<AuthenticatedUser>,
2674 Extension(session): Extension<session::SessionHandle>,
2675 headers: axum::http::HeaderMap,
2676 Form(pairs): Form<Vec<(String, String)>>| {
2677 let cfg = cfg.clone();
2678 let path = list_path.clone();
2679 async move {
2680 list::reorder_handler(
2681 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
2682 &headers, &pairs,
2683 )
2684 .await
2685 }
2686 },
2687 ),
2688 );
2689 }
2690
2691 if !resource.relations.is_empty() && resource.form.is_none() {
2694 panic!(
2695 "admin resource `{}` declares relations but has no form to show them on",
2696 resource.base_path
2697 );
2698 }
2699 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
2700 for rel in &resource.relations {
2701 let prepared = relation::PreparedRelation::prepare(
2702 resource,
2703 rel.clone(),
2704 field_types,
2705 column_types,
2706 permission_codes,
2707 persisters,
2708 listeners,
2709 )
2710 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2711 relations.push(Arc::new(prepared));
2712 }
2713 let relations = Arc::new(relations);
2714
2715 let mut prepared_form = None;
2716 if let Some(form_cfg) = resource.form.clone() {
2717 let mut prepared =
2720 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2721 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2722 prepared.stay = !relations.is_empty();
2724 let prepared = Arc::new(prepared);
2725 prepared_form = Some(prepared.clone());
2726 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
2727 let relations_for_new = relations.clone();
2728 router = router.route(
2729 &format!("{base}/new"),
2730 get(
2731 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
2732 let pf = new_pf.clone();
2733 let relations = relations_for_new.clone();
2734 async move {
2735 let pending = relations
2736 .iter()
2737 .map(|r| shell.tt(&r.config.title))
2738 .collect();
2739 form::new_form(&state, &pf, shell, pending)
2740 }
2741 },
2742 )
2743 .post(
2744 move |State(state): State<AdminState>,
2745 Extension(shell): Extension<Shell>,
2746 Extension(user): Extension<AuthenticatedUser>,
2747 Extension(session): Extension<session::SessionHandle>,
2748 headers: axum::http::HeaderMap,
2749 Form(data): Form<HashMap<String, String>>| {
2750 let pf = create_pf.clone();
2751 async move {
2752 form::create(&state, &pf, data, shell, &user, &session, &headers).await
2753 }
2754 },
2755 ),
2756 );
2757
2758 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
2759 let relations_for_edit = relations.clone();
2760 router = router.route(
2761 &format!("{base}/{{id}}/edit"),
2762 get(
2763 move |State(state): State<AdminState>,
2764 Extension(shell): Extension<Shell>,
2765 Extension(user): Extension<AuthenticatedUser>,
2766 Path(id): Path<String>| {
2767 let pf = edit_pf.clone();
2768 let relations = relations_for_edit.clone();
2769 async move {
2770 let mut sections = Vec::new();
2771 for rel in relations.iter() {
2772 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
2773 sections.push(section);
2774 }
2775 }
2776 form::edit_form(&state, &pf, id, shell, sections).await
2777 }
2778 },
2779 )
2780 .post(
2781 move |State(state): State<AdminState>,
2782 Extension(shell): Extension<Shell>,
2783 Extension(user): Extension<AuthenticatedUser>,
2784 Extension(session): Extension<session::SessionHandle>,
2785 Path(id): Path<String>,
2786 headers: axum::http::HeaderMap,
2787 Form(data): Form<HashMap<String, String>>| {
2788 let pf = update_pf.clone();
2789 async move {
2790 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
2791 }
2792 },
2793 ),
2794 );
2795 }
2796
2797 for rel in relations.iter() {
2798 let name = rel.config.name.clone();
2799 let mut routes: Router<AdminState> = Router::new();
2800 let r = rel.clone();
2801 routes =
2802 routes.route(
2803 &format!("{base}/{{id}}/{name}"),
2804 get(
2805 move |state: State<AdminState>,
2806 shell: Extension<Shell>,
2807 user: Extension<AuthenticatedUser>,
2808 id: Path<String>,
2809 params: Query<list::ListParams>,
2810 raw: Query<HashMap<String, String>>,
2811 headers: axum::http::HeaderMap| {
2812 let r = r.clone();
2813 async move {
2814 relation::index(state, shell, user, id, params, raw, headers, r).await
2815 }
2816 },
2817 ),
2818 );
2819 let (r_new, r_create) = (rel.clone(), rel.clone());
2820 routes = routes.route(
2821 &format!("{base}/{{id}}/{name}/new"),
2822 get(
2823 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
2824 let r = r_new.clone();
2825 async move { relation::new_form(state, shell, id, r).await }
2826 },
2827 )
2828 .post(
2829 move |state: State<AdminState>,
2830 shell: Extension<Shell>,
2831 user: Extension<AuthenticatedUser>,
2832 session: Extension<session::SessionHandle>,
2833 id: Path<String>,
2834 headers: axum::http::HeaderMap,
2835 data: Form<HashMap<String, String>>| {
2836 let r = r_create.clone();
2837 async move {
2838 relation::create(state, shell, user, session, id, headers, data, r).await
2839 }
2840 },
2841 ),
2842 );
2843 let (r_edit, r_update) = (rel.clone(), rel.clone());
2844 routes = routes.route(
2845 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
2846 get(
2847 move |state: State<AdminState>,
2848 shell: Extension<Shell>,
2849 ids: Path<(String, String)>| {
2850 let r = r_edit.clone();
2851 async move { relation::edit_form(state, shell, ids, r).await }
2852 },
2853 )
2854 .post(
2855 move |state: State<AdminState>,
2856 shell: Extension<Shell>,
2857 user: Extension<AuthenticatedUser>,
2858 session: Extension<session::SessionHandle>,
2859 ids: Path<(String, String)>,
2860 headers: axum::http::HeaderMap,
2861 data: Form<HashMap<String, String>>| {
2862 let r = r_update.clone();
2863 async move {
2864 relation::update(state, shell, user, session, ids, headers, data, r).await
2865 }
2866 },
2867 ),
2868 );
2869 let r_delete = rel.clone();
2870 routes = routes.route(
2871 &format!("{base}/{{id}}/{name}/delete"),
2872 post(
2873 move |state: State<AdminState>,
2874 shell: Extension<Shell>,
2875 user: Extension<AuthenticatedUser>,
2876 session: Extension<session::SessionHandle>,
2877 id: Path<String>,
2878 headers: axum::http::HeaderMap,
2879 pairs: Form<Vec<(String, String)>>| {
2880 let r = r_delete.clone();
2881 async move {
2882 relation::delete(state, shell, user, session, id, headers, pairs, r).await
2883 }
2884 },
2885 ),
2886 );
2887 if rel.reorderable() {
2888 let r_reorder = rel.clone();
2889 routes = routes.route(
2890 &format!("{base}/{{id}}/{name}/reorder"),
2891 post(
2892 move |state: State<AdminState>,
2893 shell: Extension<Shell>,
2894 user: Extension<AuthenticatedUser>,
2895 session: Extension<session::SessionHandle>,
2896 id: Path<String>,
2897 headers: axum::http::HeaderMap,
2898 pairs: Form<Vec<(String, String)>>| {
2899 let r = r_reorder.clone();
2900 async move {
2901 relation::reorder(state, shell, user, session, id, headers, pairs, r)
2902 .await
2903 }
2904 },
2905 ),
2906 );
2907 }
2908 if let Some(permission) = &rel.config.permission {
2909 routes = guard_with_permission(routes, permission);
2910 }
2911 router = router.merge(routes);
2912 }
2913
2914 if let Some(permission) = &resource.permission {
2916 router = guard_with_permission(router, permission);
2917 }
2918 (router, prepared_form)
2919}
2920
2921fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
2926 let needed: Arc<str> = Arc::from(permission);
2927 router.route_layer(middleware::from_fn(
2928 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
2929 let needed = needed.clone();
2930 async move {
2931 if user.allows(&needed) {
2932 next.run(req).await
2933 } else {
2934 forbidden()
2935 }
2936 }
2937 },
2938 ))
2939}
2940
2941async fn require_auth(
2947 State(state): State<AdminState>,
2948 jar: CookieJar,
2949 request: Request,
2950 next: Next,
2951) -> Response {
2952 let login = format!("{}/login", state.admin_path);
2953 let client_ctx = request
2954 .extensions()
2955 .get::<RequestContext>()
2956 .cloned()
2957 .unwrap_or_default();
2958 let mut ended: Option<laterite_auth::SessionEnd> = None;
2963 let live = match jar.get(SESSION_COOKIE) {
2964 Some(cookie) => {
2965 let token = cookie.value().to_string();
2966 match state.auth.resolve_session(&token).await {
2967 Ok(resolved) => Some((token, resolved)),
2968 Err(_) => {
2969 ended = state.auth.session_end(&token).await.ok().flatten();
2970 None
2971 }
2972 }
2973 }
2974 None => None,
2975 };
2976 let htmx = request.headers().contains_key("hx-request");
2977 let (token, resolved, recalled) = match live {
2978 Some((token, resolved)) => (token, resolved, None),
2979 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2982 return signed_out(&state, jar, &login, ended, htmx);
2983 }
2984 None => match recall(&state, &jar, &client_ctx).await {
2985 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2986 None => {
2987 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2991 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2992 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2993 }
2994 }
2995 return signed_out(&state, jar, &login, ended, htmx);
2996 }
2997 },
2998 };
2999 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
3000
3001 let mut request = request;
3006 let safe = session::is_safe_method(request.method());
3007 let mut deferred: Option<session::CsrfPending> = None;
3009 if !safe {
3010 let (parts, body) = request.into_parts();
3011 let (body, submitted) = if session::is_multipart(&parts.headers) {
3018 let token = session::header_token(&parts.headers)
3019 .or_else(|| session::query_token(parts.uri.query()));
3020 if token.is_none() {
3025 deferred = Some(session::CsrfPending::default());
3026 }
3027 (body, token)
3028 } else {
3029 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
3030 .await
3031 .unwrap_or_default();
3032 let token = session::submitted_token(&parts.headers, &bytes);
3033 (Body::from(bytes), token)
3034 };
3035 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
3036 {
3037 tracing::warn!(
3038 user_id = resolved.identity.user.id,
3039 "admin CSRF check failed"
3040 );
3041 return error::csrf_rejected();
3042 }
3043 request = Request::from_parts(parts, body);
3044 }
3045
3046 let user = resolved.identity;
3047 let acting_user_id = user.user.id;
3049 let path = request.uri().path().to_string();
3050 let preferences = format!("{}/preferences", state.admin_path);
3053 if !path.starts_with(&preferences)
3054 && path != format!("{}/logout", state.admin_path)
3055 && state
3056 .auth
3057 .must_change_password(user.user.id)
3058 .await
3059 .unwrap_or(false)
3060 {
3061 let to = format!("{preferences}#password");
3062 let response = if htmx {
3063 (
3064 [("HX-Redirect", to.as_str())],
3065 axum::http::StatusCode::NO_CONTENT,
3066 )
3067 .into_response()
3068 } else {
3069 Redirect::to(&to).into_response()
3070 };
3071 return with_recall_cookies(response, recalled, jar, token, &state);
3072 }
3073 let flash = if safe {
3076 handle.take_flash()
3077 } else {
3078 Vec::new()
3079 };
3080 let accept_language = request
3084 .headers()
3085 .get("accept-language")
3086 .and_then(|v| v.to_str().ok());
3087 let serveable = state.catalogs.locales();
3088 let chain = resolve_locale_chain(
3089 user.user.locale.as_deref(),
3090 accept_language,
3091 &state.default_locale,
3092 &serveable,
3093 );
3094 let i18n = Translator::with_chain(chain, state.catalogs.clone());
3095 let translator = i18n.clone();
3098 let own = OwnMenu::load(&state, user.user.id).await;
3100 let (own_nav, own_settings) = own_tree(&state, &own);
3101 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
3102 &own_nav,
3103 &own_settings,
3104 &state.admin_path,
3105 &user.permissions,
3106 own.nav_order.as_deref(),
3107 &path,
3108 &i18n,
3109 icons::Icons::new(&state.icons, &state.sprite_url),
3110 &state.order,
3111 own.settings_order.as_deref(),
3112 );
3113 let brand = state.brand().await;
3114 let shell = Shell::new(
3115 &state.admin_path,
3116 brand,
3117 &visible_nav,
3118 &user,
3119 state.timezone,
3120 sidebar,
3121 active_nav.as_deref(),
3122 handle.csrf_token(),
3123 flash,
3124 i18n,
3125 state.asset_urls.clone(),
3126 state.origin.to_string(),
3127 state.icons.clone(),
3128 state.sprite_url.clone(),
3129 );
3130 request.extensions_mut().insert(user);
3131 request.extensions_mut().insert(shell);
3132 request.extensions_mut().insert(handle.clone());
3133 if let Some(pending) = &deferred {
3135 request.extensions_mut().insert(pending.clone());
3136 }
3137 let mut response = next.run(request).await;
3138
3139 if let Some(pending) = &deferred {
3144 if !pending.was_satisfied() {
3145 tracing::error!(
3146 user_id = acting_user_id,
3147 "a multipart route returned without verifying its request token"
3148 );
3149 return error::csrf_rejected();
3150 }
3151 }
3152
3153 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
3157 response = error::localized_error(meta.kind, &translator);
3158 }
3159
3160 if let Some(blob) = handle.dirty_blob() {
3163 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
3164 tracing::error!(error = %e, "persisting admin session failed");
3165 }
3166 }
3167 with_recall_cookies(response, recalled, jar, token, &state)
3168}
3169
3170fn with_recall_cookies(
3174 response: Response,
3175 recalled: Option<laterite_auth::RememberCredential>,
3176 jar: CookieJar,
3177 token: String,
3178 state: &AdminState,
3179) -> Response {
3180 match recalled {
3181 Some(credential) => {
3182 let jar = jar
3183 .add(session_cookie(
3184 token,
3185 &state.cookie_path,
3186 state.secure_cookie,
3187 ))
3188 .add(remember_cookie(
3189 credential,
3190 &state.cookie_path,
3191 state.secure_cookie,
3192 ));
3193 (jar, response).into_response()
3194 }
3195 None => response,
3196 }
3197}
3198
3199async fn recall(
3203 state: &AdminState,
3204 jar: &CookieJar,
3205 ctx: &RequestContext,
3206) -> Option<(
3207 String,
3208 laterite_auth::ResolvedSession,
3209 laterite_auth::RememberCredential,
3210)> {
3211 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
3212 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
3213 let token = recalled.session.token.clone();
3214 let resolved = state.auth.resolve_session(&token).await.ok()?;
3215 Some((token, resolved, recalled.remember))
3216}
3217
3218const MAX_FORM_BYTES: usize = 1024 * 1024;
3221
3222async fn capture_client(
3230 State(state): State<AdminState>,
3231 mut request: Request,
3232 next: Next,
3233) -> Response {
3234 let peer = request
3235 .extensions()
3236 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3237 .map(|info| info.0);
3238 let headers = request.headers();
3239 let ctx = RequestContext {
3240 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3241 user_agent: clientip::user_agent(headers),
3242 };
3243 request.extensions_mut().insert(ctx);
3244 next.run(request).await
3245}
3246
3247async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3248 if !session::is_safe_method(request.method())
3249 && !session::origin_ok(request.headers(), &state.origin)
3250 {
3251 tracing::warn!(
3252 path = request.uri().path(),
3253 "admin CSRF origin check failed"
3254 );
3255 return error::csrf_rejected();
3256 }
3257 next.run(request).await
3258}
3259
3260#[derive(Deserialize)]
3261struct LoginQuery {
3262 ended: Option<String>,
3263}
3264
3265async fn login_form(
3266 State(state): State<AdminState>,
3267 headers: HeaderMap,
3268 Query(query): Query<LoginQuery>,
3269) -> Response {
3270 match state.auth.has_any_operator().await {
3272 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
3273 Ok(true) => {
3274 let i18n = pre_auth_translator(&state, &headers);
3275 let notice = query
3276 .ended
3277 .as_deref()
3278 .and_then(end_notice)
3279 .map(|text| i18n.t(&text));
3280 render(LoginTemplate {
3281 base: state.admin_path.to_string(),
3282 asset_urls: state.asset_urls.clone(),
3283 brand: state.brand().await,
3284 error: None,
3285 notice,
3286 i18n,
3287 })
3288 }
3289 Err(_) => render_error(),
3290 }
3291}
3292
3293#[derive(Deserialize)]
3294struct LoginForm {
3295 username: String,
3296 password: String,
3297 #[serde(default)]
3299 remember: Option<String>,
3300}
3301
3302async fn login_submit(
3303 State(state): State<AdminState>,
3304 jar: CookieJar,
3305 headers: HeaderMap,
3306 Extension(ctx): Extension<RequestContext>,
3307 Form(form): Form<LoginForm>,
3308) -> Response {
3309 match state
3310 .auth
3311 .authenticate(&form.username, &form.password, &ctx)
3312 .await
3313 {
3314 Ok(session) => {
3315 let wants_remember = form.remember.is_some();
3316 let user_id = session.user_id;
3317 let mut jar = jar.add(session_cookie(
3318 session.token,
3319 &state.cookie_path,
3320 state.secure_cookie,
3321 ));
3322 if wants_remember {
3323 match state.auth.issue_remember(user_id).await {
3324 Ok(credential) => {
3325 jar = jar.add(remember_cookie(
3326 credential,
3327 &state.cookie_path,
3328 state.secure_cookie,
3329 ));
3330 }
3331 Err(e) => {
3333 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
3334 }
3335 }
3336 }
3337 (jar, Redirect::to(&state.admin_path)).into_response()
3338 }
3339 Err(_) => {
3340 let i18n = pre_auth_translator(&state, &headers);
3341 render(LoginTemplate {
3342 base: state.admin_path.to_string(),
3343 asset_urls: state.asset_urls.clone(),
3344 brand: state.brand().await,
3345 error: Some(i18n.t(&t!("Invalid username or password."))),
3346 notice: None,
3347 i18n,
3348 })
3349 }
3350 }
3351}
3352
3353fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
3363 Cookie::build((SESSION_COOKIE, token))
3364 .path(admin_path.to_string())
3365 .http_only(true)
3366 .secure(secure)
3367 .same_site(SameSite::Lax)
3368 .build()
3369}
3370
3371fn remember_cookie(
3374 credential: laterite_auth::RememberCredential,
3375 admin_path: &str,
3376 secure: bool,
3377) -> Cookie<'static> {
3378 let seconds = (credential.expires_at - chrono::Utc::now())
3379 .num_seconds()
3380 .max(0);
3381 Cookie::build((REMEMBER_COOKIE, credential.cookie))
3382 .path(admin_path.to_string())
3383 .http_only(true)
3384 .secure(secure)
3385 .same_site(SameSite::Lax)
3386 .max_age(cookie::time::Duration::seconds(seconds))
3387 .build()
3388}
3389
3390fn signed_out(
3397 state: &AdminState,
3398 jar: CookieJar,
3399 login: &str,
3400 ended: Option<laterite_auth::SessionEnd>,
3401 htmx: bool,
3402) -> Response {
3403 let to = match ended.map(laterite_auth::SessionEnd::code) {
3404 Some(code) => format!("{login}?ended={code}"),
3405 None => login.to_string(),
3406 };
3407 let jar = jar
3408 .remove(
3409 Cookie::build((SESSION_COOKIE, ""))
3410 .path(state.cookie_path.to_string())
3411 .build(),
3412 )
3413 .remove(remember_removal(&state.cookie_path));
3414 if htmx {
3415 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
3416 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
3417 response.headers_mut().insert("hx-redirect", value);
3418 }
3419 return response;
3420 }
3421 (jar, Redirect::to(&to)).into_response()
3422}
3423
3424fn end_notice(code: &str) -> Option<Text> {
3427 Some(match code {
3428 "password_changed" => {
3429 t!("Your password was changed, so you were signed out. Sign in with the new one.")
3430 }
3431 "deactivated" => t!("This account was deactivated."),
3432 "signed_out_elsewhere" => t!("You were signed out from another device."),
3433 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3434 _ => return None,
3435 })
3436}
3437
3438fn remember_removal(admin_path: &str) -> Cookie<'static> {
3440 Cookie::build((REMEMBER_COOKIE, ""))
3441 .path(admin_path.to_string())
3442 .build()
3443}
3444
3445#[derive(Deserialize)]
3446struct SetupForm {
3447 username: String,
3448 first_name: String,
3449 last_name: String,
3450 email: String,
3451 password: String,
3452 timezone: String,
3453}
3454
3455async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3458 match state.auth.has_any_operator().await {
3459 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3460 Ok(false) => render(setup_view(
3461 &state.admin_path,
3462 state.brand().await,
3463 state.timezone,
3464 None,
3465 pre_auth_translator(&state, &headers),
3466 state.asset_urls.clone(),
3467 state.auth.password_policy().min_length,
3468 )),
3469 Err(_) => render_error(),
3470 }
3471}
3472
3473async fn setup_submit(
3474 State(state): State<AdminState>,
3475 jar: CookieJar,
3476 headers: HeaderMap,
3477 client: Option<Extension<RequestContext>>,
3478 Form(form): Form<SetupForm>,
3479) -> Response {
3480 match state.auth.has_any_operator().await {
3482 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3483 Ok(false) => {}
3484 Err(_) => return render_error(),
3485 }
3486
3487 let username = form.username.trim();
3488 let email = form.email.trim();
3489 let first_name = form.first_name.trim();
3490 let last_name = form.last_name.trim();
3491 let tz = form.timezone.trim();
3492 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3493 {
3494 return render(setup_view(
3495 &state.admin_path,
3496 state.brand().await,
3497 state.timezone,
3498 Some(t!(
3499 "Username, first name, email, and password are all required."
3500 )),
3501 pre_auth_translator(&state, &headers),
3502 state.asset_urls.clone(),
3503 state.auth.password_policy().min_length,
3504 ));
3505 }
3506 if tz.parse::<Tz>().is_err() {
3508 return render(setup_view(
3509 &state.admin_path,
3510 state.brand().await,
3511 state.timezone,
3512 Some(t!("That is not a recognised timezone.")),
3513 pre_auth_translator(&state, &headers),
3514 state.asset_urls.clone(),
3515 state.auth.password_policy().min_length,
3516 ));
3517 }
3518
3519 let min_length = state.auth.password_policy().min_length;
3520 if form.password.chars().count() < min_length {
3521 return render(setup_view(
3522 &state.admin_path,
3523 state.brand().await,
3524 state.timezone,
3525 Some(t!(
3526 "A password needs at least {n} characters.",
3527 n = min_length as i64
3528 )),
3529 pre_auth_translator(&state, &headers),
3530 state.asset_urls.clone(),
3531 state.auth.password_policy().min_length,
3532 ));
3533 }
3534 let new = NewOperator {
3535 username,
3536 email,
3537 first_name,
3538 last_name: (!last_name.is_empty()).then_some(last_name),
3539 password: &form.password,
3540 timezone: Some(tz),
3541 };
3542 let id = match state.auth.create_superuser(new).await {
3543 Ok(id) => id,
3544 Err(_) => {
3545 return render(setup_view(
3546 &state.admin_path,
3547 state.brand().await,
3548 state.timezone,
3549 Some(t!(
3550 "Could not create the account. The username or email may already be taken."
3551 )),
3552 pre_auth_translator(&state, &headers),
3553 state.asset_urls.clone(),
3554 state.auth.password_policy().min_length,
3555 ));
3556 }
3557 };
3558 let actor = laterite_core::Actor::system("first-run setup");
3561 let target = id.to_string();
3562 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
3563 "backend_user",
3564 &target,
3565 Some(username),
3566 );
3567 if let Err(e) = state.auth.record_audit(entry).await {
3568 tracing::error!(error = %e, "failed to write audit log entry");
3569 }
3570
3571 match state
3573 .auth
3574 .authenticate(
3575 username,
3576 &form.password,
3577 &client.map(|c| c.0).unwrap_or_default(),
3578 )
3579 .await
3580 {
3581 Ok(session) => {
3582 let cookie = session_cookie(session.token, &state.cookie_path, state.secure_cookie);
3583 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
3584 }
3585 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3586 }
3587}
3588
3589fn setup_view(
3592 admin_path: &str,
3593 brand: String,
3594 default_tz: Tz,
3595 error: Option<Text>,
3596 i18n: Translator,
3597 asset_urls: Arc<AssetUrls>,
3598 min_password: usize,
3599) -> SetupTemplate {
3600 let default_name = default_tz.name();
3601 let zones = TZ_VARIANTS
3602 .iter()
3603 .filter(|tz| zone_offered(tz.name()))
3604 .map(|tz| TzOption {
3605 name: tz.name().to_string(),
3606 selected: tz.name() == default_name,
3607 })
3608 .collect();
3609 SetupTemplate {
3610 base: admin_path.to_string(),
3611 asset_urls,
3612 brand,
3613 zones,
3614 error: error.map(|e| i18n.t(&e)),
3615 min_password: min_password as i64,
3616 i18n,
3617 }
3618}
3619
3620async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
3621 if let Some(cookie) = jar.get(SESSION_COOKIE) {
3622 let _ = state.auth.logout(cookie.value()).await;
3623 }
3624 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3627 let _ = state.auth.revoke_remember(cookie.value()).await;
3628 }
3629 let removal = Cookie::build((SESSION_COOKIE, ""))
3630 .path(state.cookie_path.to_string())
3631 .build();
3632 (
3633 jar.remove(removal)
3634 .remove(remember_removal(&state.cookie_path)),
3635 Redirect::to(&format!("{}/login", state.admin_path)),
3636 )
3637 .into_response()
3638}
3639
3640async fn dashboard(
3641 Extension(shell): Extension<Shell>,
3642 Extension(user): Extension<AuthenticatedUser>,
3643) -> Response {
3644 render(DashboardTemplate {
3645 username: user.user.username,
3646 shell,
3647 })
3648}
3649
3650fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
3653 match &item.permission {
3654 None => true,
3655 Some(p) => perms.allows(p),
3656 }
3657}
3658
3659fn visible_settings(
3663 items: &[settings::SettingsItem],
3664 perms: &PermissionSet,
3665) -> Vec<settings::SettingsItem> {
3666 items
3667 .iter()
3668 .filter(|item| operator_can_see(item, perms))
3669 .cloned()
3670 .collect()
3671}
3672
3673async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
3674 settings::index(shell)
3675}
3676
3677async fn settings_edit(
3678 state: AdminState,
3679 shell: Shell,
3680 user: AuthenticatedUser,
3681 code: String,
3682) -> Response {
3683 let items = visible_settings(&state.settings, &user.permissions);
3686 match items
3687 .iter()
3688 .find(|item| item.code == code && item.link.is_none())
3689 {
3690 Some(item) => settings::edit_form(&state, item, shell, &user).await,
3691 None => not_found(),
3692 }
3693}
3694
3695async fn settings_update(
3696 state: AdminState,
3697 shell: Shell,
3698 user: AuthenticatedUser,
3699 session: session::SessionHandle,
3700 code: String,
3701 data: HashMap<String, String>,
3702) -> Response {
3703 let items = visible_settings(&state.settings, &user.permissions);
3704 match items
3705 .iter()
3706 .find(|item| item.code == code && item.link.is_none())
3707 {
3708 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
3709 None => not_found(),
3710 }
3711}
3712
3713async fn preferences_form(
3715 State(state): State<AdminState>,
3716 Extension(shell): Extension<Shell>,
3717 Extension(user): Extension<AuthenticatedUser>,
3718 jar: CookieJar,
3719) -> Response {
3720 let account = account_security(&state, &user, &shell, &jar).await;
3721 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
3722 .into_iter()
3723 .filter(|i| i.per_operator)
3724 .map(|i| PreferenceLink {
3725 label: shell.tt(&i.label),
3726 description: shell.tt(&i.description),
3727 href: i.path(&state.admin_path),
3728 })
3729 .collect();
3730 let own = OwnMenu::load(&state, user.user.id).await;
3731 let chosen = |key: &str| -> String {
3732 own.moves
3733 .get(key)
3734 .map(|m| {
3735 if m.hierarchy == nav::TOP_HIERARCHY {
3736 "nav".to_string()
3737 } else {
3738 "settings".to_string()
3739 }
3740 })
3741 .unwrap_or_default()
3742 };
3743 let mut menu_entries: Vec<MenuEntryView> = state
3744 .nav_items
3745 .iter()
3746 .filter(|i| i.path.is_some() && !matches!(i.key.as_str(), "dashboard" | "settings"))
3747 .filter(|i| {
3748 i.permission
3749 .as_deref()
3750 .is_none_or(|p| user.permissions.allows(p))
3751 })
3752 .map(|i| MenuEntryView {
3753 key: i.key.clone(),
3754 label: shell.tt(&i.label),
3755 home: "nav",
3756 chosen: chosen(&i.key),
3757 })
3758 .collect();
3759 menu_entries.extend(
3760 visible_settings(&state.settings, &user.permissions)
3761 .into_iter()
3762 .filter(|i| !i.per_operator)
3763 .map(|i| MenuEntryView {
3764 chosen: chosen(&i.code),
3765 key: i.code,
3766 label: shell.tt(&i.label),
3767 home: "settings",
3768 }),
3769 );
3770 render(preferences_view(
3771 &shell,
3772 &user,
3773 state.timezone,
3774 &state.default_locale,
3775 &offered_locales(&state.catalogs),
3776 account,
3777 state.auth.password_policy().min_length,
3778 items,
3779 menu_entries,
3780 ))
3781}
3782
3783struct PreferenceLink {
3785 label: String,
3786 description: String,
3787 href: String,
3788}
3789
3790#[derive(Deserialize)]
3791struct RevokeSessionForm {
3792 id: String,
3793}
3794
3795async fn session_revoke(
3797 State(state): State<AdminState>,
3798 Extension(user): Extension<AuthenticatedUser>,
3799 Extension(session): Extension<session::SessionHandle>,
3800 Form(form): Form<RevokeSessionForm>,
3801) -> Response {
3802 match state.auth.revoke_session(user.user.id, &form.id).await {
3803 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
3804 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
3805 Err(e) => {
3806 tracing::error!(error = %e, "revoking a session failed");
3807 session.push_flash(
3808 FlashLevel::Error,
3809 t!("That session could not be signed out."),
3810 );
3811 }
3812 }
3813 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3814}
3815
3816#[derive(Deserialize)]
3818struct MenuOrderForm {
3819 #[serde(default)]
3820 order: String,
3821 #[serde(default)]
3823 hierarchy: String,
3824}
3825
3826async fn menu_moves_update(
3830 State(state): State<AdminState>,
3831 Extension(user): Extension<AuthenticatedUser>,
3832 Extension(session): Extension<session::SessionHandle>,
3833 Form(form): Form<HashMap<String, String>>,
3834) -> Response {
3835 let mut moves = serde_json::Map::new();
3836 for (field, place) in &form {
3837 let Some(key) = field.strip_prefix("place:") else {
3838 continue;
3839 };
3840 if key.is_empty() || key.len() > 200 || !matches!(place.as_str(), "nav" | "settings") {
3841 continue;
3842 }
3843 moves.insert(key.to_string(), serde_json::Value::String(place.clone()));
3844 }
3845 let result = if moves.is_empty() {
3846 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::MOVES_PREFERENCE)
3847 .await
3848 } else {
3849 laterite_auth::store::set_user_preference(
3850 &state.db,
3851 user.user.id,
3852 nav::MOVES_PREFERENCE,
3853 &serde_json::Value::Object(moves).to_string(),
3854 )
3855 .await
3856 };
3857 if let Err(e) = result {
3858 tracing::error!(error = %e, "storing the menu moves failed");
3859 return render_error();
3860 }
3861 session.push_flash(session::FlashLevel::Success, t!("Menu saved."));
3862 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3863}
3864
3865async fn menu_order_update(
3870 State(state): State<AdminState>,
3871 Extension(user): Extension<AuthenticatedUser>,
3872 Extension(session): Extension<session::SessionHandle>,
3873 headers: axum::http::HeaderMap,
3874 Form(form): Form<MenuOrderForm>,
3875) -> Response {
3876 let order: Vec<&str> = form
3877 .order
3878 .split(',')
3879 .map(str::trim)
3880 .filter(|k| !k.is_empty() && k.len() <= 200)
3881 .take(100)
3882 .collect();
3883 let hierarchy = if form.hierarchy.is_empty() {
3884 nav::TOP_HIERARCHY
3885 } else {
3886 form.hierarchy.as_str()
3887 };
3888 if !nav::OPERATOR_HIERARCHIES.contains(&hierarchy) {
3889 return AdminError::NotFound.into_response();
3890 }
3891 let key = nav::order_preference(hierarchy);
3892 let result = if order.is_empty() {
3893 let mut cleared = Ok(());
3895 for key in nav::OPERATOR_HIERARCHIES
3896 .iter()
3897 .map(|h| nav::order_preference(h))
3898 .chain(std::iter::once(nav::MOVES_PREFERENCE.to_string()))
3899 {
3900 if let Err(e) =
3901 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
3902 {
3903 cleared = Err(e);
3904 }
3905 }
3906 cleared
3907 } else {
3908 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &order.join(","))
3909 .await
3910 };
3911 if let Err(e) = result {
3912 tracing::error!(error = %e, "storing the menu order failed");
3913 return render_error();
3914 }
3915 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
3916 return axum::http::StatusCode::NO_CONTENT.into_response();
3917 }
3918 session.push_flash(
3919 FlashLevel::Success,
3920 if order.is_empty() {
3921 t!("The menu is back in its default order.")
3922 } else {
3923 t!("Saved.")
3924 },
3925 );
3926 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3927}
3928
3929async fn session_revoke_others(
3930 State(state): State<AdminState>,
3931 Extension(user): Extension<AuthenticatedUser>,
3932 Extension(session): Extension<session::SessionHandle>,
3933 jar: CookieJar,
3934) -> Response {
3935 let token = jar
3936 .get(SESSION_COOKIE)
3937 .map(|c| c.value().to_string())
3938 .unwrap_or_default();
3939 match state.auth.sign_out_everywhere(user.user.id, &token).await {
3940 Ok(_) => session.push_flash(
3941 FlashLevel::Success,
3942 t!("Every other session was signed out."),
3943 ),
3944 Err(e) => {
3945 tracing::error!(error = %e, "signing out other sessions failed");
3946 session.push_flash(
3947 FlashLevel::Error,
3948 t!("Those sessions could not be signed out."),
3949 );
3950 }
3951 }
3952 (
3955 jar.remove(remember_removal(&state.cookie_path)),
3956 Redirect::to(&format!("{}/preferences", state.admin_path)),
3957 )
3958 .into_response()
3959}
3960
3961#[derive(Deserialize)]
3962struct PasswordForm {
3963 current_password: String,
3964 new_password: String,
3965 confirm_password: String,
3966}
3967
3968async fn password_update(
3973 State(state): State<AdminState>,
3974 Extension(user): Extension<AuthenticatedUser>,
3975 Extension(session): Extension<session::SessionHandle>,
3976 Extension(ctx): Extension<RequestContext>,
3977 jar: CookieJar,
3978 Form(form): Form<PasswordForm>,
3979) -> Response {
3980 let back = format!("{}/preferences#password", state.admin_path);
3981 let refuse = |message: Text| {
3982 session.push_flash(FlashLevel::Error, message);
3983 Redirect::to(&back).into_response()
3984 };
3985 if form.new_password != form.confirm_password {
3986 return refuse(t!("The new passwords do not match."));
3987 }
3988 let min_length = state.auth.password_policy().min_length;
3989 if form.new_password.chars().count() < min_length {
3990 return refuse(t!(
3991 "A password needs at least {n} characters.",
3992 n = min_length as i64
3993 ));
3994 }
3995 let remembered_here = match jar.get(REMEMBER_COOKIE) {
3998 Some(cookie) => {
3999 state
4000 .auth
4001 .remember_holder(cookie.value())
4002 .await
4003 .ok()
4004 .flatten()
4005 == Some(user.user.id)
4006 }
4007 None => false,
4008 };
4009 let token = jar
4010 .get(SESSION_COOKIE)
4011 .map(|c| c.value().to_string())
4012 .unwrap_or_default();
4013 let changed = state
4014 .auth
4015 .change_own_password(
4016 user.user.id,
4017 &form.current_password,
4018 &form.new_password,
4019 &token,
4020 &ctx,
4021 )
4022 .await;
4023 match changed {
4024 Ok(()) => {}
4025 Err(laterite_auth::AuthError::InvalidCredentials) => {
4026 return refuse(t!("Your current password is not correct."));
4027 }
4028 Err(laterite_auth::AuthError::TooManyAttempts) => {
4029 return refuse(t!("Too many attempts. Try again later."));
4030 }
4031 Err(e) => {
4032 tracing::error!(error = %e, "changing a password failed");
4033 return refuse(t!("The password could not be changed."));
4034 }
4035 }
4036 session.push_flash_sticky(
4037 FlashLevel::Success,
4038 t!("Password changed. Every other device was signed out."),
4039 );
4040 let jar = if remembered_here {
4041 match state.auth.issue_remember(user.user.id).await {
4042 Ok(credential) => jar.add(remember_cookie(
4043 credential,
4044 &state.cookie_path,
4045 state.secure_cookie,
4046 )),
4047 Err(e) => {
4048 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
4049 jar.remove(remember_removal(&state.cookie_path))
4050 }
4051 }
4052 } else if jar.get(REMEMBER_COOKIE).is_some() {
4053 jar.remove(remember_removal(&state.cookie_path))
4056 } else {
4057 jar
4058 };
4059 (
4060 jar,
4061 Redirect::to(&format!("{}/preferences", state.admin_path)),
4062 )
4063 .into_response()
4064}
4065
4066#[derive(Deserialize)]
4067struct PreferencesForm {
4068 timezone: String,
4069 #[serde(default)]
4071 locale: String,
4072}
4073
4074async fn preferences_update(
4075 State(state): State<AdminState>,
4076 Extension(user): Extension<AuthenticatedUser>,
4077 Extension(session): Extension<session::SessionHandle>,
4078 Form(form): Form<PreferencesForm>,
4079) -> Response {
4080 let back = format!("{}/preferences", state.admin_path);
4081 let offered = offered_locales(&state.catalogs);
4082 let tz = form.timezone.trim();
4084 let tz_stored = if tz.is_empty() {
4085 None
4086 } else if tz.parse::<Tz>().is_ok() {
4087 Some(tz)
4088 } else {
4089 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
4090 return Redirect::to(&back).into_response();
4091 };
4092 let loc = form.locale.trim();
4093 let loc_stored = if loc.is_empty() {
4094 None
4095 } else if offered.iter().any(|l| l == loc) {
4096 Some(loc)
4097 } else {
4098 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
4099 return Redirect::to(&back).into_response();
4100 };
4101 if state
4102 .auth
4103 .set_user_timezone(user.user.id, tz_stored)
4104 .await
4105 .is_err()
4106 {
4107 return render_error();
4108 }
4109 match state.auth.set_user_locale(user.user.id, loc_stored).await {
4110 Ok(()) => {
4111 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
4112 Redirect::to(&back).into_response()
4113 }
4114 Err(_) => render_error(),
4115 }
4116}
4117
4118#[allow(clippy::too_many_arguments)]
4122fn preferences_view(
4123 shell: &Shell,
4124 user: &AuthenticatedUser,
4125 default_tz: Tz,
4126 default_locale: &str,
4127 offered: &[String],
4128 account: AccountSecurity,
4129 min_password: usize,
4130 items: Vec<PreferenceLink>,
4131 menu_entries: Vec<MenuEntryView>,
4132) -> PreferencesTemplate {
4133 let current = user.user.timezone.as_deref();
4134 let zones = TZ_VARIANTS
4135 .iter()
4136 .filter(|tz| zone_offered(tz.name()))
4137 .map(|tz| TzOption {
4138 name: tz.name().to_string(),
4139 selected: current == Some(tz.name()),
4140 })
4141 .collect();
4142 let current_locale = user.user.locale.as_deref();
4143 let locales = offered
4144 .iter()
4145 .map(|code| LocaleOption {
4146 name: locale_name(code),
4147 selected: current_locale == Some(code.as_str()),
4148 code: code.clone(),
4149 })
4150 .collect();
4151 PreferencesTemplate {
4152 shell: shell.clone(),
4153 zones,
4154 effective_tz: shell.tz.name().to_string(),
4155 default_tz: default_tz.name().to_string(),
4156 inherits: current.is_none(),
4157 locales,
4158 default_locale: locale_name(default_locale),
4159 inherits_locale: current_locale.is_none(),
4160 sessions: account.sessions,
4161 password_changed: account.password_changed,
4162 must_change: account.must_change,
4163 min_password: min_password as i64,
4164 items,
4165 menu_entries,
4166 }
4167}
4168
4169struct AccountSecurity {
4172 sessions: Vec<SessionRow>,
4173 password_changed: Option<String>,
4174 must_change: bool,
4176}
4177
4178async fn account_security(
4179 state: &AdminState,
4180 user: &AuthenticatedUser,
4181 shell: &Shell,
4182 jar: &CookieJar,
4183) -> AccountSecurity {
4184 let locale = list::date_locale(shell.locale());
4185 let password_changed = match state.auth.password_changed_at(user.user.id).await {
4186 Ok(at) => {
4187 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
4188 }
4189 Err(e) => {
4190 tracing::error!(error = %e, "reading the password change time failed");
4191 None
4192 }
4193 };
4194 AccountSecurity {
4195 sessions: session_rows(state, user, shell, jar).await,
4196 password_changed,
4197 must_change: state
4198 .auth
4199 .must_change_password(user.user.id)
4200 .await
4201 .unwrap_or(false),
4202 }
4203}
4204
4205async fn session_rows(
4208 state: &AdminState,
4209 user: &AuthenticatedUser,
4210 shell: &Shell,
4211 jar: &CookieJar,
4212) -> Vec<SessionRow> {
4213 let token = jar
4214 .get(SESSION_COOKIE)
4215 .map(|c| c.value().to_string())
4216 .unwrap_or_default();
4217 let locale = list::date_locale(shell.locale());
4218 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
4219 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
4220 };
4221 match state.auth.list_sessions(user.user.id, &token).await {
4222 Ok(sessions) => sessions
4223 .into_iter()
4224 .map(|s| SessionRow {
4225 id: s.id,
4226 started: stamp(s.created_at),
4227 last_seen: stamp(s.last_seen_at),
4228 expires: stamp(s.expires_at),
4229 device: clientip::describe(s.user_agent.as_deref())
4230 .or(s.user_agent)
4231 .unwrap_or_else(|| "-".to_string()),
4232 ip_address: s.ip_address.unwrap_or_default(),
4233 current: s.current,
4234 })
4235 .collect(),
4236 Err(e) => {
4237 tracing::error!(error = %e, "listing sessions failed");
4238 Vec::new()
4239 }
4240 }
4241}
4242
4243fn builtin_resources() -> Vec<Resource> {
4245 vec![
4246 Resource {
4247 base_path: "/users".to_string(),
4248 nav_label: "Administrators".into(),
4249 list: backend_users_list_config(),
4250 form: None,
4251 permission: Some("backend.manage_users".to_string()),
4252 import: None,
4253 palette: None,
4254 relations: Vec::new(),
4255 menu: nav::Placement::default(),
4256 },
4257 Resource {
4258 base_path: "/roles".to_string(),
4259 nav_label: "Roles".into(),
4260 list: roles_list_config(),
4261 form: None,
4264 permission: Some("backend.manage_roles".to_string()),
4265 import: None,
4266 palette: None,
4267 relations: Vec::new(),
4268 menu: nav::Placement::default(),
4269 },
4270 resource!("descriptors/audit-log.yaml"),
4271 resource!("descriptors/access-log.yaml"),
4272 resource!("descriptors/request-log.yaml"),
4273 ]
4274}
4275
4276fn builtin_settings() -> Vec<settings::SettingsItem> {
4280 vec![
4281 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
4282 .description("Manage backend administrator accounts.")
4283 .category("Users")
4284 .slot(laterite_core::arrange::slot::PEOPLE)
4285 .order(10)
4286 .icon("users")
4287 .permission("backend.manage_users")
4288 .link("/users"),
4289 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
4290 .description("Manage roles and their permissions.")
4291 .category("Users")
4292 .slot(laterite_core::arrange::slot::PEOPLE)
4293 .order(20)
4294 .icon("shield")
4295 .permission("backend.manage_roles")
4296 .link("/roles"),
4297 settings::brand::settings_item(),
4298 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
4299 .description("Enable or disable installed plugins.")
4300 .category("System")
4301 .slot(laterite_core::arrange::slot::SYSTEM)
4302 .order(10)
4303 .icon("plug")
4304 .permission(plugins::MANAGE_PERMISSION)
4305 .link("/plugins"),
4306 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
4307 .description("Review the record of administrative changes.")
4308 .category("System")
4309 .slot(laterite_core::arrange::slot::SYSTEM)
4310 .order(20)
4311 .icon("history")
4312 .permission("backend.view_audit_log")
4313 .link("/audit-log"),
4314 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
4315 .description("Review sign-ins, failures and lockouts.")
4316 .category("System")
4317 .slot(laterite_core::arrange::slot::SYSTEM)
4318 .order(30)
4319 .icon("log-in")
4320 .permission("backend.view_access_log")
4321 .link("/access-log"),
4322 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
4323 .description("Review the URLs the panel could not serve.")
4324 .category("System")
4325 .slot(laterite_core::arrange::slot::SYSTEM)
4326 .order(31)
4327 .icon("unlink")
4328 .permission("backend.view_request_log")
4329 .link("/request-log"),
4330 ]
4331}
4332
4333fn backend_users_list_config() -> list::ListConfig {
4334 list::ListConfig {
4335 entity: "backend_users".to_string(),
4336 title: "Administrators".into(),
4337 columns: vec![
4338 list::ListColumn::new("username", "Username"),
4339 list::ListColumn::new("email", "Email"),
4340 list::ListColumn::new("first_name", "First name"),
4341 list::ListColumn::new("last_name", "Last name"),
4342 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
4343 list::ListColumn::new("is_active", "Active").yes_no(),
4344 list::ListColumn::new("created_at", "Created").datetime(),
4345 ],
4346 order_by: "created_at".to_string(),
4347 creatable: true,
4350 edit_base: Some("/users".to_string()),
4351 filters: vec![
4352 list::ListFilter::boolean("is_active", "Active"),
4353 list::ListFilter::boolean("is_superuser", "Superuser"),
4354 ],
4355 ..Default::default()
4359 }
4360}
4361
4362fn roles_list_config() -> list::ListConfig {
4363 list::ListConfig {
4364 entity: "backend_roles".to_string(),
4365 title: "Roles".into(),
4366 columns: vec![
4367 list::ListColumn::new("code", "Code"),
4368 list::ListColumn::new("name", "Name"),
4369 list::ListColumn::new("created_at", "Created").datetime(),
4370 ],
4371 order_by: "created_at".to_string(),
4372 edit_base: Some("/roles".to_string()),
4373 creatable: true,
4374 deletable: true,
4375 ..Default::default()
4376 }
4377}
4378
4379#[derive(Template)]
4380#[template(path = "login.html")]
4381struct LoginTemplate {
4382 base: String,
4384 asset_urls: Arc<AssetUrls>,
4386 brand: String,
4387 error: Option<String>,
4388 notice: Option<String>,
4390 i18n: Translator,
4393}
4394
4395fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
4398 match urls.get(key) {
4399 Some(path) => format!("{base}/assets/{path}"),
4400 None => {
4401 debug_assert!(false, "asset key `{key}` is not registered");
4402 format!("{base}/assets/{key}")
4403 }
4404 }
4405}
4406
4407impl LoginTemplate {
4408 fn asset(&self, key: &str) -> String {
4409 preauth_asset(&self.asset_urls, &self.base, key)
4410 }
4411 fn t(&self, source: &str) -> String {
4412 self.i18n.t(&Text::dynamic(source))
4413 }
4414 fn locale(&self) -> &str {
4415 self.i18n.locale()
4416 }
4417}
4418
4419#[derive(Template)]
4420#[template(path = "dashboard.html")]
4421struct DashboardTemplate {
4422 shell: Shell,
4423 username: String,
4424}
4425
4426#[derive(Template)]
4427#[template(path = "setup.html")]
4428struct SetupTemplate {
4429 base: String,
4431 asset_urls: Arc<AssetUrls>,
4433 brand: String,
4434 zones: Vec<TzOption>,
4435 error: Option<String>,
4436 i18n: Translator,
4438 min_password: i64,
4440}
4441
4442impl SetupTemplate {
4443 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
4446 let mut text = Text::dynamic(source);
4447 for (name, value) in args {
4448 text = text.arg(*name, *value);
4449 }
4450 self.i18n.t(&text)
4451 }
4452
4453 fn asset(&self, key: &str) -> String {
4454 preauth_asset(&self.asset_urls, &self.base, key)
4455 }
4456 fn t(&self, source: &str) -> String {
4457 self.i18n.t(&Text::dynamic(source))
4458 }
4459 fn locale(&self) -> &str {
4460 self.i18n.locale()
4461 }
4462}
4463
4464#[derive(Template)]
4465#[template(path = "preferences.html")]
4466struct PreferencesTemplate {
4467 shell: Shell,
4468 zones: Vec<TzOption>,
4469 effective_tz: String,
4471 default_tz: String,
4473 inherits: bool,
4475 locales: Vec<LocaleOption>,
4476 default_locale: String,
4478 inherits_locale: bool,
4480 sessions: Vec<SessionRow>,
4482 password_changed: Option<String>,
4484 must_change: bool,
4486 min_password: i64,
4488 items: Vec<PreferenceLink>,
4490 menu_entries: Vec<MenuEntryView>,
4492}
4493
4494struct MenuEntryView {
4497 key: String,
4498 label: String,
4499 home: &'static str,
4501 chosen: String,
4503}
4504
4505struct SessionRow {
4507 id: String,
4508 started: String,
4509 last_seen: String,
4510 expires: String,
4511 device: String,
4514 ip_address: String,
4516 current: bool,
4517}
4518
4519struct TzOption {
4520 name: String,
4521 selected: bool,
4522}
4523
4524struct LocaleOption {
4525 code: String,
4527 name: String,
4529 selected: bool,
4530}
4531
4532#[derive(Clone)]
4534struct CrumbView {
4535 label: String,
4536 href: String,
4537}
4538
4539#[derive(Clone)]
4540struct NavView {
4541 key: String,
4543 label: String,
4544 path: String,
4545 active: bool,
4546 icon: String,
4549}
4550
4551pub(crate) fn render<T: Template>(template: T) -> Response {
4553 match template.render() {
4554 Ok(html) => Html(html).into_response(),
4555 Err(e) => AdminError::from(e).into_response(),
4557 }
4558}
4559
4560pub(crate) fn render_error() -> Response {
4561 error::masked_500()
4562}
4563
4564pub(crate) fn not_found() -> Response {
4565 AdminError::NotFound.into_response()
4566}
4567
4568pub(crate) fn forbidden() -> Response {
4569 AdminError::Forbidden.into_response()
4570}
4571
4572#[cfg(test)]
4573mod tests {
4574 use super::*;
4575
4576 fn write_form() -> form::FormConfig {
4577 form::FormConfig {
4578 enter: Default::default(),
4579 confirm_leave: true,
4580 focus: Default::default(),
4581 entity: "widgets".to_string(),
4582 title: "Widgets".into(),
4583 base_path: "/widgets".to_string(),
4584 id_field: "id".to_string(),
4585 fields: vec![],
4586 persist: None,
4587 timestamps: false,
4588 done_path: None,
4589 }
4590 }
4591
4592 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
4593 Resource {
4594 base_path: "/widgets".to_string(),
4595 nav_label: "Widgets".into(),
4596 list: backend_users_list_config(),
4597 form,
4598 permission: permission.map(str::to_string),
4599 import: None,
4600 palette: None,
4601 relations: Vec::new(),
4602 menu: nav::Placement::default(),
4603 }
4604 }
4605
4606 #[test]
4607 #[should_panic(expected = "must declare a permission")]
4608 fn write_resource_without_permission_aborts_boot() {
4609 let resource = resource_with(Some(write_form()), None);
4612 let _ = mount_resource(
4613 &resource,
4614 &field::builtin_registry(),
4615 &list::builtin_column_registry(),
4616 &std::collections::HashSet::new(),
4617 &persist::PersisterRegistry::new(),
4618 &[],
4619 );
4620 }
4621
4622 #[test]
4624 fn the_access_log_is_read_from_its_descriptor_file() {
4625 let access = builtin_resources()
4626 .into_iter()
4627 .find(|r| r.base_path == "/access-log")
4628 .expect("the access log is registered");
4629 assert_eq!(access.list.entity, "backend_access_log");
4630 assert_eq!(
4631 access.permission.as_deref(),
4632 Some("backend.view_access_log")
4633 );
4634 assert!(access.form.is_none(), "append-only");
4635 assert_eq!(
4636 access
4637 .list
4638 .columns
4639 .iter()
4640 .map(|c| c.field.as_str())
4641 .collect::<Vec<_>>(),
4642 [
4643 "created_at",
4644 "username_attempted",
4645 "event",
4646 "ip_address",
4647 "user_agent"
4648 ]
4649 );
4650 }
4651
4652 #[test]
4655 fn the_audit_log_is_read_from_its_descriptor_file() {
4656 let audit = builtin_resources()
4657 .into_iter()
4658 .find(|r| r.base_path == "/audit-log")
4659 .expect("the audit log is registered");
4660 assert_eq!(audit.list.entity, "backend_audit_log");
4661 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
4662 assert_eq!(
4663 audit
4664 .list
4665 .columns
4666 .iter()
4667 .map(|c| c.field.as_str())
4668 .collect::<Vec<_>>(),
4669 [
4670 "created_at",
4671 "actor_username",
4672 "action",
4673 "target_type",
4674 "target_id",
4675 "target_label"
4676 ],
4677 "in the order the file writes them"
4678 );
4679 assert_eq!(audit.list.columns[0].column_type, "datetime");
4680 assert_eq!(audit.list.per_page, 50);
4681 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
4682 assert!(audit.list.exportable, "evidence may leave the panel");
4683 assert!(audit.form.is_none(), "append-only: no form");
4684 assert!(!audit.list.deletable, "and nothing removes from it");
4685 }
4686
4687 #[test]
4688 #[should_panic(expected = "uses unregistered type `nope`")]
4689 fn unregistered_column_type_aborts_boot() {
4690 let mut resource = resource_with(None, None);
4691 let mut score = list::ListColumn::new("score", "Score");
4692 score.column_type = "nope".into();
4693 resource.list.columns.push(score);
4694 let _ = mount_resource(
4695 &resource,
4696 &field::builtin_registry(),
4697 &list::builtin_column_registry(),
4698 &std::collections::HashSet::new(),
4699 &persist::PersisterRegistry::new(),
4700 &[],
4701 );
4702 }
4703
4704 #[test]
4705 fn read_only_resource_may_omit_permission() {
4706 let resource = resource_with(None, None);
4708 let _ = mount_resource(
4709 &resource,
4710 &field::builtin_registry(),
4711 &list::builtin_column_registry(),
4712 &std::collections::HashSet::new(),
4713 &persist::PersisterRegistry::new(),
4714 &[],
4715 );
4716 }
4717
4718 #[test]
4719 fn shell_localizes_through_the_translator_and_falls_back() {
4720 let shell = Shell::test();
4722 assert_eq!(shell.t("Save"), "Save");
4723 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
4724 }
4725
4726 #[test]
4727 fn shell_tf_interpolates_integer_arguments() {
4728 let shell = Shell::test();
4729 assert_eq!(
4730 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
4731 "Page 2 of 5"
4732 );
4733 }
4734
4735 #[test]
4736 fn display_tz_prefers_a_valid_operator_preference() {
4737 assert_eq!(
4738 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
4739 Tz::Asia__Kolkata
4740 );
4741 }
4742
4743 #[test]
4744 fn display_tz_falls_back_when_unset_or_invalid() {
4745 let default = Tz::Europe__London;
4746 assert_eq!(resolve_display_tz(None, default), default);
4748 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
4750 assert_eq!(resolve_display_tz(Some(""), default), default);
4751 }
4752
4753 #[test]
4754 fn accept_language_orders_by_weight_and_drops_wildcard() {
4755 assert_eq!(
4756 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
4757 vec!["en-US", "en", "kn"]
4758 );
4759 assert_eq!(
4761 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
4762 vec!["kn", "en"]
4763 );
4764 }
4765
4766 #[test]
4767 fn locale_chain_prefers_operator_then_header_then_default() {
4768 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
4770 assert_eq!(
4773 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
4774 vec!["kn", "ta", "hi", "en"]
4775 );
4776 assert_eq!(
4778 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
4779 vec!["kn", "en", "ta"]
4780 );
4781 assert_eq!(
4783 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
4784 vec!["kn", "en"]
4785 );
4786 }
4787
4788 #[test]
4789 fn locale_chain_skips_unserveable_and_defaults_to_en() {
4790 let loaded = ["hi".to_string()];
4792 assert_eq!(
4795 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
4796 vec!["en"]
4797 );
4798 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
4800 assert_eq!(
4802 resolve_locale_chain(Some("hi"), None, "en", &loaded),
4803 vec!["hi", "en"]
4804 );
4805 assert_eq!(
4807 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
4808 vec![laterite_core::PSEUDO_LOCALE, "en"]
4809 );
4810 }
4811
4812 #[test]
4813 fn page_assets_dedupe_preserve_order_and_classify() {
4814 let mut reg = AssetRegistry::new();
4815 reg.insert(
4816 "a.js",
4817 AdminAsset {
4818 mime: "text/javascript; charset=utf-8",
4819 bytes: std::borrow::Cow::Borrowed(b"//"),
4820 },
4821 );
4822 reg.insert(
4823 "b.css",
4824 AdminAsset {
4825 mime: "text/css; charset=utf-8",
4826 bytes: std::borrow::Cow::Borrowed(b"body{}"),
4827 },
4828 );
4829 let urls = asset_urls(®);
4832 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
4833 assert_eq!(assets.len(), 2);
4834 assert_eq!(
4835 assets[0].url,
4836 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
4837 );
4838 assert!(!assets[0].css);
4839 assert_eq!(
4840 assets[1].url,
4841 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
4842 );
4843 assert!(assets[1].css);
4844 assert_ne!(assets[0].url, assets[1].url);
4847 }
4848
4849 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
4853 laterite_core::testing::connect_test(&[]).await
4854 }
4855
4856 #[tokio::test]
4857 async fn builtin_migrations_create_the_admin_tables() {
4858 let (db, _guard) = empty_db().await;
4859 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
4860 .await
4861 .unwrap();
4862 for table in ["backend_users", "settings"] {
4866 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
4867 .fetch_optional(&db.pool)
4868 .await;
4869 assert!(
4870 probe.is_ok(),
4871 "{table} should exist after builtin_migrations"
4872 );
4873 }
4874 }
4875
4876 #[tokio::test]
4877 async fn brand_setting_overrides_config_and_blank_falls_back() {
4878 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
4879 let state = AdminState {
4880 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
4881 db: db.clone(),
4882 nav: Arc::new(nav::NavTree::default()),
4883 nav_items: Arc::new(Vec::new()),
4884 settings: Arc::new(Vec::new()),
4885 permissions: Arc::new(builtin_permissions()),
4886 trusted_proxies: Arc::new(Vec::new()),
4887 icons: Arc::new(laterite_core::icons::IconSet::new()),
4888 sprite_url: Arc::from(""),
4889 admin_path: Arc::from("/admin"),
4890 cookie_path: Arc::from("/admin"),
4891 storage_dir: Arc::from("storage"),
4892 signing_key: Arc::new(random_key()),
4893 order: std::collections::BTreeMap::new(),
4894 secure_cookie: false,
4895 origin: Arc::from(""),
4896 timezone: Tz::UTC,
4897 default_locale: Arc::from("en"),
4898 catalogs: Arc::new(CatalogStore::default()),
4899 app_name: "Configured Name".to_string(),
4900 brand_cache: Arc::new(RwLock::new(None)),
4901 field_types: Arc::new(field::builtin_registry()),
4902 column_types: Arc::new(list::builtin_column_registry()),
4903 plugin_defined: Arc::new(laterite_core::Registry::new()),
4904 assets: Arc::new(builtin_assets()),
4905 asset_urls: Arc::new(asset_urls(&builtin_assets())),
4906 pickers: Arc::new(picker::PickerRegistry::new()),
4907 palette: Arc::new(Vec::new()),
4908 overrides: Arc::new(field::NoOverrides),
4909 };
4910
4911 assert_eq!(state.brand().await, "Configured Name");
4913
4914 settings::store::save(
4917 &db,
4918 &settings::BrandSetting {
4919 app_name: "Acme Corp".to_string(),
4920 },
4921 )
4922 .await
4923 .unwrap();
4924 state.invalidate_brand();
4925 assert_eq!(state.brand().await, "Acme Corp");
4926
4927 settings::store::save(
4929 &db,
4930 &settings::BrandSetting {
4931 app_name: " ".to_string(),
4932 },
4933 )
4934 .await
4935 .unwrap();
4936 state.invalidate_brand();
4937 assert_eq!(state.brand().await, "Configured Name");
4938 }
4939
4940 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
4941 settings::SettingsItem {
4942 code: code.to_string(),
4943 label: code.into(),
4944 description: String::new().into(),
4945 category: "General".into(),
4946 hint: None,
4947 confirm_leave: true,
4948 slot: laterite_core::arrange::slot::APPLICATION,
4949 order: 1,
4950 icon: None,
4951 permission: permission.map(str::to_string),
4952 link: None,
4953 fields: Vec::new(),
4954 route: None,
4955 per_operator: false,
4956 }
4957 }
4958
4959 #[test]
4960 fn settings_visibility_respects_permissions() {
4961 let items = vec![
4962 settings_item("public", None),
4963 settings_item("gated", Some("backend.manage_users")),
4964 ];
4965
4966 let none = PermissionSet::new(false, Vec::<String>::new());
4968 let codes: Vec<String> = visible_settings(&items, &none)
4969 .into_iter()
4970 .map(|i| i.code)
4971 .collect();
4972 assert_eq!(codes, ["public"]);
4973
4974 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
4976 assert_eq!(visible_settings(&items, &granted).len(), 2);
4977
4978 let superuser = PermissionSet::new(true, Vec::<String>::new());
4980 assert_eq!(visible_settings(&items, &superuser).len(), 2);
4981 }
4982
4983 #[test]
4984 fn context_sidebar_follows_settings_links() {
4985 let items = builtin_settings();
4986 let superuser = PermissionSet::new(true, Vec::<String>::new());
4987 let tr = Translator::new("en");
4988 let tree = nav::NavTree::default();
4989 let sidebar = |path: &str| {
4990 resolve_nav_context(
4991 &tree,
4992 &items,
4993 "/admin",
4994 &superuser,
4995 None,
4996 path,
4997 &tr,
4998 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
4999 &std::collections::BTreeMap::new(),
5000 None,
5001 )
5002 .1
5003 };
5004 let active_path = |path: &str| -> Option<String> {
5005 sidebar(path)
5006 .into_iter()
5007 .flat_map(|g| g.items)
5008 .find(|i| i.active)
5009 .map(|i| i.path)
5010 };
5011
5012 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
5014 assert_eq!(
5015 active_path("/admin/roles/42/edit").as_deref(),
5016 Some("/admin/roles")
5017 );
5018 assert!(!sidebar("/admin/settings").is_empty());
5020 assert_eq!(active_path("/admin/settings"), None);
5021 assert_eq!(
5026 active_path("/admin/settings/branding").as_deref(),
5027 Some("/admin/settings/branding")
5028 );
5029 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
5032 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
5033 assert!(sidebar("/admin").is_empty());
5035 }
5036}
5037
5038#[cfg(test)]
5039mod session_cookie_tests {
5040 use super::*;
5041
5042 #[test]
5046 fn the_session_cookie_never_outlives_the_browser() {
5047 let cookie = session_cookie("tok".into(), "/admin", false);
5048 assert_eq!(cookie.max_age(), None);
5049 assert!(cookie.http_only().unwrap_or(false));
5050 assert_eq!(cookie.path(), Some("/admin"));
5051 }
5052
5053 #[test]
5056 fn the_remember_cookie_carries_its_own_lifetime() {
5057 let credential = laterite_auth::RememberCredential {
5058 cookie: "sel:ver".into(),
5059 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
5060 };
5061 let cookie = remember_cookie(credential, "/admin", true);
5062 let age = cookie.max_age().expect("a remember cookie must persist");
5063 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
5065 assert!(cookie.http_only().unwrap_or(false));
5066 assert!(cookie.secure().unwrap_or(false));
5067 }
5068
5069 #[test]
5071 fn the_removal_matches_the_cookie_it_clears() {
5072 let removal = remember_removal("/admin");
5073 assert_eq!(removal.name(), REMEMBER_COOKIE);
5074 assert_eq!(removal.path(), Some("/admin"));
5075 }
5076}