1mod audit;
18pub mod bootstrap;
19mod bulk;
20pub mod checklist;
21mod clientip;
22pub mod descriptor;
23pub mod editor;
24mod error;
25mod export;
26pub mod field;
27pub mod form;
28pub mod html;
29pub mod http_cache;
30mod icons;
31pub mod import;
32mod jobs;
33pub(crate) mod keyed;
34pub mod list;
35pub mod medialib;
36pub mod nav;
37pub(crate) mod palette;
38pub mod persist;
39pub mod picker;
40pub mod plugins;
41pub mod relation;
42pub(crate) mod requestlog;
43mod richtext;
44mod roles;
45mod routemap;
46pub mod routes;
47mod scheduled;
48mod session;
49pub mod settings;
50pub mod skin;
51mod sql;
52pub mod ui;
53mod upload;
54mod users;
55pub mod widget;
56mod widgets;
57
58pub use axum;
66
67pub use bootstrap::{
68 crypt_from_config, legacy_key_material, AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX,
69};
70pub use error::AdminError;
71pub use session::{FlashLevel, SessionHandle};
72pub use upload::VerifiedUpload;
73
74use std::collections::HashMap;
75use std::sync::Arc;
76
77use askama::Template;
78use axum::body::Body;
79use axum::extract::{Path, Query, Request, State};
80use axum::http::{header, HeaderMap};
81use axum::middleware::{self, Next};
82use axum::response::{Html, IntoResponse, Redirect, Response};
83use axum::routing::{any, get, post};
84use axum::{Extension, Form, Router};
85use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
86use chrono_tz::{Tz, TZ_VARIANTS};
87use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
88use laterite_core::{t, CatalogStore, Db, Text, Translator};
89use serde::{Deserialize, Serialize};
90
91pub trait AdminRegistry {
97 fn add_resource(&mut self, resource: Resource);
99 fn add_permission(&mut self, permission: Permission);
101 fn add_settings(&mut self, item: settings::SettingsItem);
103 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
105 fn add_persister(&mut self, persister: persist::PersisterReg);
107 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
109 fn add_screen(&mut self, screen: routes::ScreenReg);
112 fn add_public_route(&mut self, route: routes::PublicRouteReg);
115 fn add_nav(&mut self, item: nav::NavItem);
117 fn add_resource_hook(&mut self, hook: ResourceHook);
119 fn add_editor_command(&mut self, command: editor::EditorCommand);
121 fn add_snippet_source(&mut self, source: std::sync::Arc<dyn editor::SnippetSource>);
123 fn add_widget(&mut self, widget: widget::WidgetReg);
125 fn add_skin(&mut self, skin: skin::Skin);
127 fn add_asset(&mut self, key: &'static str, asset: AdminAsset);
131}
132
133impl AdminRegistry for laterite_core::Registry {
134 fn add_resource(&mut self, resource: Resource) {
135 self.add(resource);
136 }
137 fn add_permission(&mut self, permission: Permission) {
138 self.add(permission);
139 }
140 fn add_settings(&mut self, item: settings::SettingsItem) {
141 self.add(item);
142 }
143 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
144 self.add(source);
145 }
146 fn add_screen(&mut self, screen: routes::ScreenReg) {
147 self.add(screen);
148 }
149 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
150 self.add(route);
151 }
152 fn add_nav(&mut self, item: nav::NavItem) {
153 self.add(item);
154 }
155 fn add_resource_hook(&mut self, hook: ResourceHook) {
156 self.add(hook);
157 }
158 fn add_editor_command(&mut self, command: editor::EditorCommand) {
159 self.add(command);
160 }
161 fn add_snippet_source(&mut self, source: std::sync::Arc<dyn editor::SnippetSource>) {
162 self.add(editor::SnippetSourceReg(source));
163 }
164 fn add_widget(&mut self, widget: widget::WidgetReg) {
165 self.add(widget);
166 }
167 fn add_skin(&mut self, skin: skin::Skin) {
168 self.add(skin);
169 }
170 fn add_asset(&mut self, key: &'static str, asset: AdminAsset) {
171 self.add(AssetReg { key, asset });
172 }
173 fn add_persister(&mut self, persister: persist::PersisterReg) {
174 self.add(persister);
175 }
176 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
177 self.add(field_type);
178 }
179}
180
181#[derive(Clone)]
188pub struct ResourceHook {
189 pub target: String,
190 apply: Arc<dyn Fn(Resource) -> Resource + Send + Sync>,
191}
192
193impl ResourceHook {
194 pub fn new(
195 target: impl Into<String>,
196 apply: impl Fn(Resource) -> Resource + Send + Sync + 'static,
197 ) -> Self {
198 Self {
199 target: target.into(),
200 apply: Arc::new(apply),
201 }
202 }
203
204 pub fn apply(&self, resource: Resource) -> Resource {
206 (self.apply)(resource)
207 }
208}
209
210impl std::fmt::Debug for ResourceHook {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 write!(f, "ResourceHook({})", self.target)
213 }
214}
215
216pub(crate) const SESSION_COOKIE: &str = "laterite_session";
217const REMEMBER_COOKIE: &str = "laterite_remember";
221
222pub(crate) const ICON_SPRITE: &str = "icons.svg";
225
226#[derive(Clone)]
228pub(crate) struct AdminState {
229 auth: AuthService,
230 db: Db,
231 parameters: laterite_core::parameters::Parameters,
233 upload_extensions: Arc<Vec<String>>,
235 nav: Arc<nav::NavTree>,
236 nav_items: Arc<Vec<nav::NavItem>>,
239 settings: Arc<Vec<settings::SettingsItem>>,
240 permissions: Arc<Vec<Permission>>,
241 admin_path: Arc<str>,
245 cookie_path: Arc<str>,
249 storage_dir: Arc<str>,
251 crypt: Arc<laterite_core::Crypt>,
253 order: std::collections::BTreeMap<String, Vec<String>>,
255 secure_cookie: bool,
256 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
258 icons: Arc<laterite_core::icons::IconSet>,
260 sprite_url: Arc<str>,
262 origin: Arc<str>,
264 timezone: Tz,
265 default_locale: Arc<str>,
269 catalogs: Arc<CatalogStore>,
272 app_name: String,
275 cache: laterite_core::Cache,
278 queue: laterite_core::Queue,
280 schedule: laterite_core::Schedule,
282 skin: Arc<skin::Active>,
284 asset_urls: Arc<AssetUrls>,
286 field_types: Arc<field::FieldRegistry>,
289 column_types: Arc<list::ColumnRegistry>,
292 widgets: Arc<Vec<widget::WidgetReg>>,
295 plugin_defined: Arc<laterite_core::Registry>,
298 assets: Arc<AssetRegistry>,
300 pickers: Arc<picker::PickerRegistry>,
303 palette: Arc<Vec<palette::RecordSource>>,
306 overrides: Arc<dyn field::OverrideResolver>,
310}
311
312impl AdminState {
313 #[cfg(test)]
314 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
315 Self {
316 auth,
317 parameters: laterite_core::parameters::Parameters::new(db.clone()),
318 cache: laterite_core::Cache::local(),
319 queue: inline_queue(&db, laterite_core::Cache::local()),
320 schedule: empty_schedule(
321 &db,
322 laterite_core::Cache::local(),
323 inline_queue(&db, laterite_core::Cache::local()),
324 ),
325 upload_extensions: Arc::new(upload_extensions(&[])),
326 db,
327 nav: Arc::new(nav::NavTree::default()),
328 nav_items: Arc::new(Vec::new()),
329 settings: Arc::new(Vec::new()),
330 permissions: Arc::new(builtin_permissions()),
331 trusted_proxies: Arc::new(Vec::new()),
332 icons: Arc::new(laterite_core::icons::IconSet::new()),
333 sprite_url: Arc::from(""),
334 admin_path: Arc::from("/admin"),
335 cookie_path: Arc::from("/admin"),
336 storage_dir: Arc::from("storage"),
337 crypt: Arc::new(laterite_core::Crypt::ephemeral()),
338 order: std::collections::BTreeMap::new(),
339 secure_cookie: false,
340 origin: Arc::from(""),
341 timezone: Tz::UTC,
342 default_locale: Arc::from("en"),
343 catalogs: Arc::new(CatalogStore::default()),
344 app_name: "Laterite".to_string(),
345 skin: Arc::new(skin::Active::default()),
346 field_types: Arc::new(field::builtin_registry()),
347 column_types: Arc::new(list::builtin_column_registry()),
348 widgets: Arc::new(widgets::builtin()),
349 plugin_defined: Arc::new(laterite_core::Registry::new()),
350 assets: Arc::new(builtin_assets()),
351 asset_urls: Arc::new(asset_urls(&builtin_assets())),
352 pickers: Arc::new(picker::PickerRegistry::new()),
353 palette: Arc::new(Vec::new()),
354 overrides: Arc::new(field::NoOverrides),
355 }
356 }
357
358 async fn brand(&self) -> Arc<settings::brand::Brand> {
362 let resolved = self
363 .cache
364 .remember(
365 BRAND_CACHE_KEY,
366 BRAND_TTL,
367 &[settings::NAMESPACE],
368 || async { Ok(settings::brand::Brand::resolve(&self.db, &self.app_name).await) },
369 )
370 .await;
371 Arc::new(match resolved {
372 Ok(brand) => brand,
373 Err(e) => {
374 tracing::warn!(error = %e, "brand not cached");
375 settings::brand::Brand::resolve(&self.db, &self.app_name).await
376 }
377 })
378 }
379
380 pub(crate) fn settings_store(&self) -> settings::Settings {
382 settings::Settings::new(self.db.clone(), self.cache.clone())
383 }
384}
385
386const BRAND_CACHE_KEY: &str = "laterite.backend::brand";
388const BRAND_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
390
391#[derive(Clone)]
397#[non_exhaustive]
398pub struct AdminConfig {
399 pub secure_cookie: bool,
402 pub trusted_proxies: Vec<String>,
406 pub timezone: String,
410 pub locale: String,
414 pub app_name: String,
418 pub path: String,
422 pub origin: String,
427 pub storage_dir: String,
432 pub skin: String,
435 pub upload_extensions: Vec<String>,
438 pub order: std::collections::BTreeMap<String, Vec<String>>,
441 pub menu: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
443 pub secret: Vec<u8>,
448 pub crypt: Option<Arc<laterite_core::Crypt>>,
451 pub cache: Option<laterite_core::Cache>,
454 pub queue: Option<laterite_core::Queue>,
458 pub schedule: Option<laterite_core::Schedule>,
461}
462
463impl Default for AdminConfig {
464 fn default() -> Self {
465 Self {
466 secure_cookie: false,
467 trusted_proxies: Vec::new(),
468 timezone: "UTC".to_string(),
469 locale: "en".to_string(),
470 app_name: "Laterite".to_string(),
471 path: "/admin".to_string(),
472 origin: String::new(),
473 storage_dir: "storage".to_string(),
474 skin: "standard".to_string(),
475 upload_extensions: Vec::new(),
476 order: std::collections::BTreeMap::new(),
477 menu: std::collections::BTreeMap::new(),
478 secret: random_key(),
479 crypt: None,
480 cache: None,
481 queue: None,
482 schedule: None,
483 }
484 }
485}
486
487fn inline_queue(db: &Db, cache: laterite_core::Cache) -> laterite_core::Queue {
490 laterite_core::Queue::inline(
491 Vec::new(),
492 laterite_core::queue::Services {
493 db: db.clone(),
494 events: laterite_core::Events::new(db.clone()),
495 parameters: laterite_core::parameters::Parameters::with_cache(
496 db.clone(),
497 cache.clone(),
498 ),
499 cache,
500 },
501 )
502}
503
504fn empty_schedule(
506 db: &Db,
507 cache: laterite_core::Cache,
508 queue: laterite_core::Queue,
509) -> laterite_core::Schedule {
510 laterite_core::Schedule::new(
511 Vec::new(),
512 laterite_core::schedule::Services {
513 db: db.clone(),
514 events: laterite_core::Events::new(db.clone()),
515 parameters: laterite_core::parameters::Parameters::with_cache(
516 db.clone(),
517 cache.clone(),
518 ),
519 cache,
520 queue,
521 },
522 )
523 .expect("no entry, nothing to refuse")
524}
525
526pub(crate) fn random_key() -> Vec<u8> {
528 use rand::RngCore;
529 let mut key = vec![0u8; 32];
530 rand::thread_rng().fill_bytes(&mut key);
531 key
532}
533
534#[derive(Clone)]
543pub struct Shell {
544 pub(crate) base: String,
548 brand: String,
552 brand_look: Arc<settings::brand::Brand>,
554 skin: Arc<skin::Active>,
555 nav: Vec<NavView>,
556 full_name: String,
557 initial: String,
558 tz: Tz,
562 i18n: Translator,
566 sidebar: Vec<settings::CategoryView>,
570 crumbs: Vec<CrumbView>,
573 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
575 pub(crate) sprite_url: Arc<str>,
578 site_url: String,
582 asset_urls: Arc<AssetUrls>,
586 pub(crate) csrf_token: String,
590 pub(crate) flash: Vec<FlashLine>,
594 pub(crate) assets: Vec<PageAsset>,
598}
599
600#[derive(Template)]
602#[template(path = "page.html")]
603struct PageTemplate {
604 title: String,
605 back: Option<(String, String)>,
607 actions: Option<html::Markup>,
609 aside: Option<(String, html::Markup)>,
611 body: html::Markup,
612 shell: Shell,
613}
614
615pub struct PageHead {
626 title: Text,
627 back: Option<(Text, String)>,
628 actions: Option<html::Markup>,
629 aside: Option<(Text, html::Markup)>,
630 assets: Vec<&'static str>,
631}
632
633impl PageHead {
634 pub fn new(title: impl Into<Text>) -> Self {
635 Self {
636 title: title.into(),
637 back: None,
638 actions: None,
639 aside: None,
640 assets: Vec::new(),
641 }
642 }
643
644 pub fn assets(mut self, keys: &[&'static str]) -> Self {
648 for key in keys {
649 if !self.assets.contains(key) {
650 self.assets.push(key);
651 }
652 }
653 self
654 }
655
656 pub fn back(mut self, label: impl Into<Text>, href: impl Into<String>) -> Self {
658 self.back = Some((label.into(), href.into()));
659 self
660 }
661
662 pub fn actions(mut self, actions: html::Markup) -> Self {
665 self.actions = Some(actions);
666 self
667 }
668
669 pub fn aside(mut self, label: impl Into<Text>, panel: html::Markup) -> Self {
673 self.aside = Some((label.into(), panel));
674 self
675 }
676}
677
678#[derive(Clone)]
680pub(crate) struct PageAsset {
681 pub(crate) url: String,
682 pub(crate) css: bool,
683}
684
685#[derive(Clone)]
688pub(crate) struct FlashLine {
689 pub(crate) level: session::FlashLevel,
690 pub(crate) text: String,
691 pub(crate) sticky: bool,
693}
694
695impl Shell {
696 #[allow(clippy::too_many_arguments)]
697 fn new(
698 base: &str,
699 brand: Arc<settings::brand::Brand>,
700 skin: Arc<skin::Active>,
701 nav: &[nav::NavNode],
702 user: &AuthenticatedUser,
703 default_tz: Tz,
704 sidebar: Vec<settings::CategoryView>,
705 active_nav: Option<&str>,
706 csrf_token: String,
707 flash: Vec<session::Flash>,
708 i18n: Translator,
709 asset_urls: Arc<AssetUrls>,
710 site_url: String,
711 icon_set: Arc<laterite_core::icons::IconSet>,
712 sprite_url: Arc<str>,
713 ) -> Self {
714 let full_name = user.user.full_name();
715 let initial = full_name
716 .chars()
717 .next()
718 .map(|c| c.to_uppercase().to_string())
719 .unwrap_or_else(|| "?".to_string());
720 let flash = flash
723 .into_iter()
724 .map(|f| FlashLine {
725 sticky: f.sticky,
726 level: f.level,
727 text: i18n.t(&f.text),
728 })
729 .collect();
730 let nav: Vec<NavView> = nav
731 .iter()
732 .map(|n| NavView {
733 key: n.item.key.clone(),
734 label: i18n.t(&n.item.label),
735 path: nav::tab_path(n).unwrap_or_default().to_string(),
736 active: active_nav == Some(n.item.key.as_str()),
737 icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
738 })
739 .collect();
740 let mut crumbs: Vec<CrumbView> = nav
741 .iter()
742 .filter(|n: &&NavView| n.active)
743 .map(|n| CrumbView {
744 label: n.label.clone(),
745 href: n.path.clone(),
746 })
747 .collect();
748 if let Some(entry) = sidebar
749 .iter()
750 .flat_map(|g| g.items.iter())
751 .find(|i| i.active)
752 {
753 crumbs.push(CrumbView {
754 label: entry.label.clone(),
755 href: entry.path.clone(),
756 });
757 }
758 Shell {
759 asset_urls,
760 icon_set,
761 sprite_url,
762 site_url,
763 base: base.to_string(),
764 brand: brand.name.clone(),
765 brand_look: brand,
766 skin,
767 nav,
768 crumbs,
769 full_name,
770 initial,
771 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
772 i18n,
773 sidebar,
774 csrf_token,
775 flash,
776 assets: Vec::new(),
777 }
778 }
779
780 pub(crate) fn site_url(&self) -> &str {
782 &self.site_url
783 }
784
785 pub(crate) fn sidebar_anchored(&self) -> bool {
788 self.nav.iter().any(|n| n.active)
789 }
790
791 pub fn icon(&self, name: &str) -> String {
795 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
796 }
797
798 pub(crate) fn asset(&self, key: &str) -> String {
803 match self.asset_urls.get(key) {
804 Some(path) => format!("{}/assets/{path}", self.base),
805 None => {
806 debug_assert!(false, "asset key `{key}` is not registered");
807 format!("{}/assets/{key}", self.base)
808 }
809 }
810 }
811
812 pub fn t(&self, source: &str) -> String {
815 self.i18n.t(&Text::dynamic(source))
816 }
817
818 pub fn tt(&self, text: &Text) -> String {
821 self.i18n.t(text)
822 }
823
824 pub fn i18n(&self) -> &Translator {
827 &self.i18n
828 }
829
830 pub fn locale(&self) -> &str {
832 self.i18n.locale()
833 }
834
835 pub fn base(&self) -> &str {
837 &self.base
838 }
839
840 pub fn csrf_token(&self) -> &str {
844 &self.csrf_token
845 }
846
847 pub fn brand_logo(&self) -> Option<&str> {
849 self.brand_look.logo_url.as_deref()
850 }
851 pub fn brand_favicon(&self) -> Option<&str> {
853 self.brand_look.favicon_url.as_deref()
854 }
855 pub fn brand_css(&self) -> &str {
857 &self.brand_look.css
858 }
859 pub fn skin_code(&self) -> &str {
862 &self.skin.code
863 }
864
865 pub fn skin_css(&self) -> Option<&str> {
867 self.skin.stylesheet.as_deref()
868 }
869
870 pub fn mode_default(&self) -> &str {
871 &self.brand_look.mode
872 }
873
874 pub fn datetime(&self, utc: &str) -> String {
877 match chrono::DateTime::parse_from_rfc3339(utc) {
878 Ok(at) => at
879 .with_timezone(&self.tz)
880 .format("%Y-%m-%d %H:%M")
881 .to_string(),
882 Err(_) => utc.to_string(),
883 }
884 }
885
886 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
890 self.page_with(PageHead::new(title.clone()), body)
891 }
892
893 pub fn page_with(&self, head: PageHead, body: html::Markup) -> Response {
896 let mut shell = self.clone();
897 for key in &head.assets {
898 let url = shell.asset(key);
899 if !shell.assets.iter().any(|a| a.url == url) {
900 shell.assets.push(PageAsset {
901 url,
902 css: key.ends_with(".css"),
903 });
904 }
905 }
906 render(PageTemplate {
907 title: self.tt(&head.title),
908 back: head.back.map(|(label, href)| (self.tt(&label), href)),
909 actions: head.actions,
910 aside: head.aside.map(|(label, panel)| (self.tt(&label), panel)),
911 body,
912 shell,
913 })
914 }
915
916 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
920 let mut text = Text::dynamic(source);
921 for (name, value) in args {
922 text = text.arg(*name, *value);
923 }
924 self.i18n.t(&text)
925 }
926
927 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
930 let mut text = Text::dynamic(source);
931 for (name, value) in args {
932 text = text.arg(*name, *value);
933 }
934 self.i18n.t(&text)
935 }
936
937 #[cfg(test)]
938 pub(crate) fn test() -> Self {
939 Shell {
940 asset_urls: Arc::new(asset_urls(&builtin_assets())),
941 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
942 sprite_url: Arc::from("/admin/assets/icons.svg"),
943 site_url: "http://localhost".to_string(),
944 base: "/admin".to_string(),
945 brand: "Laterite".to_string(),
946 brand_look: Arc::new(settings::brand::Brand::of_name("Laterite")),
947 skin: Arc::new(skin::Active::default()),
948 nav: Vec::new(),
949 full_name: "Test Operator".to_string(),
950 initial: "T".to_string(),
951 tz: Tz::UTC,
952 i18n: Translator::new("en"),
953 sidebar: Vec::new(),
954 crumbs: Vec::new(),
955 csrf_token: "test-csrf-token".to_string(),
956 flash: Vec::new(),
957 assets: Vec::new(),
958 }
959 }
960}
961
962fn settings_context(
969 visible: &[settings::SettingsItem],
970 admin_path: &str,
971 path: &str,
972) -> (bool, Option<String>) {
973 if path == format!("{admin_path}/settings") {
974 return (true, None);
975 }
976 let own = visible
982 .iter()
983 .filter(|i| i.link.is_none())
984 .find(|i| path == format!("{admin_path}{}", i.route()))
985 .map(|i| i.code.clone());
986 if own.is_some() {
987 return (true, own);
988 }
989 let linked = visible
990 .iter()
991 .filter_map(|i| {
992 i.link
993 .as_deref()
994 .map(|link| (format!("{admin_path}{link}"), &i.code))
995 })
996 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
997 .max_by_key(|(link, _)| link.len())
998 .map(|(_, code)| code.clone());
999 (linked.is_some(), linked)
1000}
1001
1002#[allow(clippy::too_many_arguments)]
1008#[derive(Default)]
1011struct OwnMenu {
1012 nav_order: Option<String>,
1013 settings_order: Option<String>,
1014 moves: std::collections::BTreeMap<String, laterite_core::arrange::Move>,
1015}
1016
1017impl OwnMenu {
1018 async fn load(state: &AdminState, user_id: i64) -> Self {
1019 let read = |key: String| async move {
1020 laterite_auth::store::user_preference(&state.db, user_id, &key)
1021 .await
1022 .unwrap_or_default()
1023 };
1024 let nav_order = read(nav::order_preference(nav::TOP_HIERARCHY)).await;
1025 let settings_order = read(nav::order_preference(settings::HIERARCHY)).await;
1026 let moves = read(nav::MOVES_PREFERENCE.to_string())
1027 .await
1028 .map(|json| nav::operator_moves(&json))
1029 .unwrap_or_default();
1030 Self {
1031 nav_order,
1032 settings_order,
1033 moves,
1034 }
1035 }
1036}
1037
1038fn own_tree(
1041 state: &AdminState,
1042 own: &OwnMenu,
1043) -> (Arc<nav::NavTree>, Arc<Vec<settings::SettingsItem>>) {
1044 if own.moves.is_empty() {
1045 return (Arc::clone(&state.nav), Arc::clone(&state.settings));
1046 }
1047 let mut items: Vec<nav::NavItem> = state.nav_items.as_ref().clone();
1048 let mut settings: Vec<settings::SettingsItem> = state.settings.as_ref().clone();
1049 nav::apply_moves(&mut items, &mut settings, &own.moves);
1050 match nav::NavTree::build_with(items, &state.admin_path, &state.order) {
1051 Ok(tree) => (Arc::new(tree), Arc::new(settings)),
1052 Err(e) => {
1053 tracing::warn!(error = %e, "an operator's menu moves could not be applied");
1054 (Arc::clone(&state.nav), Arc::clone(&state.settings))
1055 }
1056 }
1057}
1058
1059#[allow(clippy::too_many_arguments)]
1060fn resolve_nav_context(
1061 nav: &nav::NavTree,
1062 items: &[settings::SettingsItem],
1063 admin_path: &str,
1064 perms: &PermissionSet,
1065 own_order: Option<&str>,
1066 path: &str,
1067 tr: &Translator,
1068 icons: icons::Icons<'_>,
1069 order: &std::collections::BTreeMap<String, Vec<String>>,
1070 own_settings_order: Option<&str>,
1071) -> (
1072 Vec<nav::NavNode>,
1073 Vec<settings::CategoryView>,
1074 Option<String>,
1075) {
1076 let mut visible_nav = nav.visible(&|code| perms.allows(code));
1077 if let Some(order) = own_order {
1078 nav::apply_order(&mut visible_nav, order);
1079 }
1080 let own_sequence = own_settings_order.map(laterite_core::arrange::parse_sequence);
1081 let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
1083 .into_iter()
1084 .filter(|i| !i.per_operator)
1085 .collect();
1086 let (in_context, active) = settings_context(&visible, admin_path, path);
1087 if in_context {
1088 let sidebar = settings::sidebar_groups(
1089 &visible,
1090 admin_path,
1091 active.as_deref(),
1092 tr,
1093 icons,
1094 order.get(settings::HIERARCHY).map(Vec::as_slice),
1095 own_sequence.as_deref(),
1096 );
1097 return (visible_nav, sidebar, Some("settings".to_string()));
1098 }
1099 let active_top = nav::active_top(&visible_nav, admin_path, path);
1100 let sidebar = active_top
1101 .map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
1102 .unwrap_or_default();
1103 let active_key = active_top.map(|node| node.item.key.clone());
1104 (visible_nav, sidebar, active_key)
1105}
1106
1107fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
1111 preference
1112 .and_then(|name| name.parse::<Tz>().ok())
1113 .unwrap_or(default_tz)
1114}
1115
1116fn zone_offered(name: &str) -> bool {
1118 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
1119}
1120
1121fn base_lang(tag: &str) -> String {
1124 tag.split(['-', '_'])
1125 .next()
1126 .unwrap_or(tag)
1127 .to_ascii_lowercase()
1128}
1129
1130fn is_serveable(base: &str, serveable: &[String]) -> bool {
1136 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
1137}
1138
1139fn default_locale(configured: &str, serveable: &[String]) -> String {
1142 let base = base_lang(configured);
1143 if is_serveable(&base, serveable) {
1144 base
1145 } else {
1146 "en".to_string()
1147 }
1148}
1149
1150fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
1154 let mut out = vec!["en".to_string()];
1155 out.extend(catalogs.locales());
1156 out
1157}
1158
1159fn locale_name(code: &str) -> String {
1164 if code == "en" {
1165 "English".to_string()
1166 } else {
1167 code.to_string()
1168 }
1169}
1170
1171fn parse_accept_language(header: &str) -> Vec<String> {
1174 let mut items: Vec<(f32, String)> = header
1175 .split(',')
1176 .filter_map(|part| {
1177 let mut bits = part.split(';');
1178 let tag = bits.next()?.trim();
1179 if tag.is_empty() || tag == "*" {
1180 return None;
1181 }
1182 let q = bits
1183 .find_map(|p| p.trim().strip_prefix("q="))
1184 .and_then(|q| q.parse::<f32>().ok())
1185 .unwrap_or(1.0);
1186 Some((q, tag.to_string()))
1187 })
1188 .collect();
1189 items.sort_by(|a, b| b.0.total_cmp(&a.0));
1191 items.into_iter().map(|(_, tag)| tag).collect()
1192}
1193
1194fn resolve_locale_chain(
1200 preference: Option<&str>,
1201 accept_language: Option<&str>,
1202 default: &str,
1203 serveable: &[String],
1204) -> Vec<String> {
1205 let mut chain: Vec<String> = Vec::new();
1206 let consider = |tag: &str, chain: &mut Vec<String>| {
1207 let base = base_lang(tag);
1208 if is_serveable(&base, serveable) && !chain.contains(&base) {
1209 chain.push(base);
1210 }
1211 };
1212 if let Some(pref) = preference {
1213 consider(pref, &mut chain);
1214 }
1215 if let Some(header) = accept_language {
1216 for tag in parse_accept_language(header) {
1217 consider(&tag, &mut chain);
1218 }
1219 }
1220 consider(default, &mut chain);
1221 consider("en", &mut chain);
1224 chain
1225}
1226
1227fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
1231 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
1232 let serveable = state.catalogs.locales();
1233 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
1234 Translator::with_chain(chain, state.catalogs.clone())
1235}
1236
1237#[derive(Serialize)]
1245#[non_exhaustive]
1246pub struct Resource {
1247 pub base_path: String,
1253 pub nav_label: Text,
1255 pub list: list::ListConfig,
1256 pub form: Option<form::FormConfig>,
1257 pub permission: Option<String>,
1264 pub import: Option<ImportConfig>,
1267 pub palette: Option<String>,
1270 pub relations: Vec<relation::RelationConfig>,
1273 pub menu: nav::Placement,
1276}
1277
1278#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1281#[non_exhaustive]
1282pub struct ImportConfig {
1283 #[serde(default)]
1286 pub permission: Option<String>,
1287}
1288
1289impl ImportConfig {
1290 pub fn new() -> Self {
1291 Self::default()
1292 }
1293
1294 pub fn permission(mut self, code: impl Into<String>) -> Self {
1297 self.permission = Some(code.into());
1298 self
1299 }
1300}
1301
1302#[derive(Clone, Serialize)]
1307#[non_exhaustive]
1308pub struct Permission {
1309 pub code: String,
1310 pub label: Text,
1312 pub group: Text,
1313 pub default_roles: Vec<String>,
1316 pub renamed_from: Vec<String>,
1319}
1320
1321pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
1322
1323impl Permission {
1324 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
1327 Self {
1328 code: code.into(),
1329 label: label.into(),
1330 group: group.into(),
1331 default_roles: Vec::new(),
1332 renamed_from: Vec::new(),
1333 }
1334 }
1335
1336 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
1342 where
1343 I: IntoIterator<Item = S>,
1344 S: Into<String>,
1345 {
1346 self.renamed_from = codes.into_iter().map(Into::into).collect();
1347 self
1348 }
1349
1350 pub fn roles<I, S>(mut self, roles: I) -> Self
1353 where
1354 I: IntoIterator<Item = S>,
1355 S: Into<String>,
1356 {
1357 self.default_roles = roles.into_iter().map(Into::into).collect();
1358 self
1359 }
1360}
1361
1362pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
1368 let permissions: Vec<Permission> = builtin_permissions()
1369 .into_iter()
1370 .chain(contributed.iter().cloned())
1371 .collect();
1372 let mut out: Vec<(String, String)> = Vec::new();
1373 for permission in &permissions {
1374 for old in &permission.renamed_from {
1375 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
1376 panic!(
1377 "permissions `{taken}` and `{}` both claim the old code `{old}`",
1378 permission.code
1379 );
1380 }
1381 if permissions.iter().any(|p| &p.code == old) {
1382 panic!(
1383 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
1384 permission.code
1385 );
1386 }
1387 out.push((old.clone(), permission.code.clone()));
1388 }
1389 }
1390 out
1391}
1392
1393pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
1399 let permissions: Vec<Permission> = builtin_permissions()
1402 .into_iter()
1403 .chain(contributed.iter().cloned())
1404 .collect();
1405 let held = |role: &str| -> Vec<String> {
1406 permissions
1407 .iter()
1408 .filter(|p| {
1409 if p.default_roles.is_empty() {
1410 role == ROLE_ADMIN
1411 } else {
1412 p.default_roles.iter().any(|r| r == role)
1413 }
1414 })
1415 .map(|p| p.code.clone())
1416 .collect()
1417 };
1418 vec![
1419 laterite_auth::store::SystemRole {
1420 code: ROLE_ADMIN,
1421 name: "Administrator",
1422 description: "Administers the panel itself: operators, roles and settings.",
1423 permissions: held(ROLE_ADMIN),
1424 },
1425 laterite_auth::store::SystemRole {
1426 code: ROLE_EDITOR,
1427 name: "Editor",
1428 description: "Works with the content the application declares.",
1429 permissions: held(ROLE_EDITOR),
1430 },
1431 ]
1432}
1433
1434impl Resource {
1435 pub fn new(
1440 base_path: impl Into<String>,
1441 nav_label: impl Into<Text>,
1442 list: list::ListConfig,
1443 ) -> Self {
1444 Self {
1445 base_path: base_path.into(),
1446 nav_label: nav_label.into(),
1447 list,
1448 form: None,
1449 permission: None,
1450 import: None,
1451 palette: None,
1452 relations: Vec::new(),
1453 menu: nav::Placement::default(),
1454 }
1455 }
1456
1457 pub fn palette(mut self, source: impl Into<String>) -> Self {
1460 self.palette = Some(source.into());
1461 self
1462 }
1463
1464 pub fn menu(mut self, placement: nav::Placement) -> Self {
1466 self.menu = placement;
1467 self
1468 }
1469
1470 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1472 self.relations.push(relation);
1473 self
1474 }
1475
1476 pub fn import(mut self, import: ImportConfig) -> Self {
1478 self.import = Some(import);
1479 self
1480 }
1481
1482 pub fn form(mut self, form: form::FormConfig) -> Self {
1485 self.form = Some(form);
1486 self
1487 }
1488
1489 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1491 self.permission = Some(permission.into());
1492 self
1493 }
1494}
1495
1496struct SystemRoleGuard;
1502
1503#[laterite_core::strata::async_trait]
1504impl laterite_core::ModelListener for SystemRoleGuard {
1505 async fn before_delete(
1506 &self,
1507 cx: &mut laterite_core::SaveCx<'_>,
1508 rec: &laterite_core::Record,
1509 ) -> Result<(), Text> {
1510 let Some(id) = rec.id() else {
1511 return Ok(());
1512 };
1513 let backend = cx.backend();
1515 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1516 Ok(true) => Err(t!(
1517 "The framework maintains this role; it cannot be deleted."
1518 )),
1519 Err(_) => Err(t!(
1521 "Could not check whether this role is maintained by the framework."
1522 )),
1523 Ok(false) => Ok(()),
1524 }
1525 }
1526}
1527
1528fn builtin_permissions() -> Vec<Permission> {
1531 let mut out = vec![
1534 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1535 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1536 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1537 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1538 Permission::new(jobs::PERMISSION, "Manage jobs", "Backend"),
1539 Permission::new(scheduled::PERMISSION, "Manage scheduled tasks", "Backend"),
1540 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1541 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1542 Permission::new(
1543 "backend.view_request_log",
1544 "View the request log",
1545 "Backend",
1546 ),
1547 ];
1548 out.extend(medialib::permissions());
1549 out
1550}
1551
1552pub fn descriptor_sources() -> Vec<String> {
1558 let mut out = Vec::new();
1559 out.extend(laterite_core::collect_sources(&builtin_resources()));
1560 out.extend(laterite_core::collect_sources(&builtin_settings()));
1561 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1562 out.sort();
1563 out.dedup();
1564 out
1565}
1566
1567pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1582 builtin_modules().iter().map(|m| m.migrations()).collect()
1583}
1584
1585pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1589 vec![
1590 Box::new(laterite_auth::AuthModule),
1591 Box::new(settings::SettingsModule),
1592 Box::new(laterite_core::parameters::ParametersModule),
1593 Box::new(laterite_core::crypt::CryptModule),
1594 Box::new(laterite_core::cache::CacheModule),
1595 Box::new(laterite_core::queue::QueueModule),
1596 Box::new(laterite_core::schedule::ScheduleModule),
1597 Box::new(requestlog::RequestLogModule),
1598 Box::new(plugins::PluginsModule),
1599 Box::new(laterite_media::MediaModule),
1600 Box::new(medialib::MediaLibraryModule),
1601 Box::new(import::ImportModule),
1602 ]
1603}
1604
1605#[derive(Default)]
1629pub struct Contributions {
1630 pub resources: Vec<Resource>,
1632 pub settings: Vec<settings::SettingsItem>,
1634 pub permissions: Vec<Permission>,
1636 pub picker_sources: Vec<picker::PickerSourceReg>,
1638 pub persisters: Vec<persist::PersisterReg>,
1640 pub listeners: Vec<laterite_core::ModelListenerReg>,
1642 pub field_types: Vec<field::FieldTypeReg>,
1646 pub column_types: Vec<list::ColumnTypeReg>,
1647 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1650 pub screens: Vec<routes::ScreenReg>,
1652 pub public_routes: Vec<routes::PublicRouteReg>,
1654 pub nav: Vec<nav::NavItem>,
1657 pub resource_hooks: Vec<ResourceHook>,
1660 pub widgets: Vec<widget::WidgetReg>,
1662 pub skins: Vec<skin::Skin>,
1664 pub assets: Vec<AssetReg>,
1666 pub plugin_defined: Arc<laterite_core::Registry>,
1671}
1672
1673pub fn router(
1674 auth: AuthService,
1675 db: Db,
1676 contributions: Contributions,
1677 config: AdminConfig,
1678 catalogs: Arc<CatalogStore>,
1679) -> Router {
1680 let Contributions {
1681 resources: app_resources,
1682 settings: app_settings,
1683 permissions: app_permissions,
1684 picker_sources: app_picker_sources,
1685 persisters: app_persisters,
1686 listeners: app_listeners,
1687 field_types: app_field_types,
1688 column_types: app_column_types,
1689 widgets: app_widgets,
1690 skins: app_skins,
1691 assets: app_assets,
1692 icons: app_icons,
1693 screens: app_screens,
1694 public_routes: app_public,
1695 nav: app_nav,
1696 resource_hooks,
1697 plugin_defined,
1698 } = contributions;
1699 let mut app_screens = app_screens;
1702 app_screens.push(medialib::screen());
1703 let mut app_public = app_public;
1704 app_public.push(medialib::serve_route());
1705 let admin_path = normalize_path(&config.path);
1706
1707 let mut resources = builtin_resources();
1708 let mut app_resources = app_resources;
1709 for hook in resource_hooks {
1712 let found = resources
1713 .iter_mut()
1714 .chain(app_resources.iter_mut())
1715 .find(|r| r.base_path == hook.target);
1716 match found {
1717 Some(resource) => {
1718 let changed = hook.apply(std::mem::replace(
1719 resource,
1720 Resource::new("", "", list::ListConfig::default()),
1721 ));
1722 *resource = changed;
1723 }
1724 None => panic!(
1725 "a resource hook targets `{}`, which no module declared",
1726 hook.target
1727 ),
1728 }
1729 }
1730 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1734 prefix_resource(&admin_path, resource);
1735 }
1736 for resource in app_resources.iter_mut() {
1740 if let Some(import) = &resource.import {
1741 let permission = import
1742 .permission
1743 .clone()
1744 .or_else(|| resource.permission.clone())
1745 .unwrap_or_else(|| {
1746 panic!(
1747 "admin resource `{}` declares `import` but no permission; importing \
1748 writes records, so it must be gated",
1749 resource.base_path
1750 )
1751 });
1752 resource.list.import = Some(routes::feature_link(
1753 &resource.base_path,
1754 "import",
1755 &permission,
1756 ));
1757 }
1758 }
1759
1760 let mut settings = builtin_settings();
1761 settings.extend(app_settings);
1762 let mut app_listeners = app_listeners;
1765 app_listeners.insert(
1766 0,
1767 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1768 );
1769
1770 let mut permissions = builtin_permissions();
1771 permissions.extend(app_permissions);
1772 let permission_codes: std::collections::HashSet<String> =
1774 permissions.iter().map(|p| p.code.clone()).collect();
1775
1776 let mut nav_items = vec![
1782 nav::NavItem::new("dashboard", "Dashboard", "")
1783 .icon("layout-dashboard")
1784 .slot(laterite_core::arrange::slot::HOME),
1785 nav::NavItem::new("settings", "Settings", "/settings")
1786 .icon("settings")
1787 .slot(laterite_core::arrange::slot::SYSTEM),
1788 ];
1789 nav_items.extend(app_nav);
1790 for resource in &app_resources {
1791 if resource.menu.hidden {
1792 continue;
1793 }
1794 let unprefixed = resource
1795 .base_path
1796 .strip_prefix(admin_path.as_str())
1797 .unwrap_or(&resource.base_path)
1798 .to_string();
1799 let mut item = nav::NavItem::new(
1800 format!("resource:{unprefixed}"),
1801 resource.nav_label.clone(),
1802 unprefixed,
1803 )
1804 .slot(resource.menu.slot)
1805 .order(resource.menu.order);
1806 item.icon = resource.menu.icon.clone();
1807 item.permission = resource.permission.clone();
1808 item.under = resource.menu.under.clone();
1809 item.after = resource.menu.after.clone();
1810 item.before = resource.menu.before.clone();
1811 nav_items.push(item);
1812 }
1813 for reg in &app_screens {
1814 let Some(label) = ®.nav_label else {
1815 continue;
1816 };
1817 if reg.menu.hidden {
1818 continue;
1819 }
1820 let mut item = nav::NavItem::new(
1821 format!("screen:{}", reg.base_path),
1822 label.clone(),
1823 reg.base_path.clone(),
1824 )
1825 .permission(reg.permission.clone())
1826 .slot(reg.menu.slot)
1827 .order(reg.menu.order);
1828 item.icon = reg.menu.icon.clone();
1829 item.under = reg.menu.under.clone();
1830 item.after = reg.menu.after.clone();
1831 item.before = reg.menu.before.clone();
1832 nav_items.push(item);
1833 }
1834 nav::apply_moves(&mut nav_items, &mut settings, &config.menu);
1837 let nav = nav::NavTree::build_with(nav_items.clone(), &admin_path, &config.order)
1838 .unwrap_or_else(|e| panic!("admin menu: {e}"));
1839 nav.log_arrangement();
1840 settings::log_arrangement(
1841 &settings,
1842 config.order.get(settings::HIERARCHY).map(Vec::as_slice),
1843 );
1844 let palette_sources: Vec<palette::RecordSource> = app_resources
1847 .iter()
1848 .filter_map(|r| {
1849 r.palette.as_ref().map(|source| palette::RecordSource {
1850 label: r.nav_label.clone(),
1851 source: source.clone(),
1852 base_path: r.base_path.clone(),
1853 permission: r.permission.clone(),
1854 })
1855 })
1856 .collect();
1857 resources.extend(app_resources);
1858
1859 let app_name = if config.app_name.trim().is_empty() {
1860 "Laterite".to_string()
1861 } else {
1862 config.app_name.clone()
1863 };
1864
1865 let mut pickers = picker::PickerRegistry::new();
1868 for reg in app_picker_sources {
1869 let name = reg.name.clone();
1870 if !field::is_name(&name, true) {
1871 panic!("invalid picker source name `{name}`");
1872 }
1873 if pickers.insert(name.clone(), reg).is_some() {
1874 panic!("duplicate picker source `{name}`");
1875 }
1876 }
1877
1878 for source in &palette_sources {
1882 if !pickers.contains_key(&source.source) {
1883 panic!(
1884 "admin resource `{}` names palette source `{}`, which no module registered",
1885 source.base_path, source.source
1886 );
1887 }
1888 }
1889 let pickers = Arc::new(pickers);
1890 let crypt: Arc<laterite_core::Crypt> = config.crypt.clone().unwrap_or_else(|| {
1893 Arc::new(laterite_core::Crypt::new(
1894 laterite_core::MasterKey::derived(0, &config.secret),
1895 Vec::new(),
1896 ))
1897 });
1898 let mut field_types = field::builtin_registry();
1899 field_types.insert(
1900 "sensitive".to_string(),
1901 Arc::new(field::SensitiveField::new(crypt.clone())),
1902 );
1903 field_types.insert(
1904 "reference".to_string(),
1905 Arc::new(field::RefPickerField::new(pickers.clone())),
1906 );
1907
1908 let mut persisters = persist::PersisterRegistry::new();
1911 for reg in app_persisters {
1912 let name = reg.name.clone();
1913 if !field::is_name(&name, true) {
1914 panic!("invalid persister name `{name}`");
1915 }
1916 if persisters.insert(name.clone(), reg.persister).is_some() {
1917 panic!("duplicate persister `{name}`");
1918 }
1919 }
1920
1921 for reg in app_field_types {
1927 let name = reg.name().to_string();
1928 if !field::is_name(&name, true) {
1929 panic!("invalid field type name `{name}`");
1930 }
1931 if field_types.insert(name.clone(), reg.field_type).is_some() {
1932 panic!("duplicate field type `{name}`");
1933 }
1934 }
1935
1936 let mut icon_set = laterite_core::icons::IconSet::new();
1940 for (owner, reg) in app_icons {
1941 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1942 panic!("module `{owner}` contributed an invalid icon: {e}");
1943 }
1944 }
1945
1946 let mut assets = builtin_assets();
1949 assets.insert(
1950 ICON_SPRITE,
1951 AdminAsset {
1952 mime: "image/svg+xml",
1953 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1954 },
1955 );
1956 for reg in app_assets {
1960 let key = reg.key;
1961 if key.is_empty()
1962 || key.starts_with('/')
1963 || key.split('/').any(|s| s.is_empty() || s == "..")
1964 {
1965 panic!("invalid admin asset key `{key}`");
1966 }
1967 if assets.insert(key, reg.asset).is_some() {
1968 panic!("duplicate admin asset `{key}`");
1969 }
1970 }
1971 let mut skins = skin::builtin();
1972 skins.extend(app_skins);
1973 skin::check(&skins);
1974
1975 for item in &settings {
1979 icons::require(
1980 &icon_set,
1981 &format!("settings item `{}`", item.code),
1982 item.icon.as_deref(),
1983 );
1984 }
1985 for node in nav
1986 .top
1987 .iter()
1988 .flat_map(|n| std::iter::once(n).chain(n.children.iter()))
1989 {
1990 icons::require(
1991 &icon_set,
1992 &format!("menu entry `{}`", node.item.key),
1993 node.item.icon.as_deref(),
1994 );
1995 }
1996
1997 let mut column_types = list::builtin_column_registry();
2001 for reg in app_column_types {
2002 let name = reg.name().to_string();
2003 if !field::is_name(&name, true) {
2004 panic!("invalid column type name `{name}`");
2005 }
2006 if column_types.insert(name.clone(), reg.column_type).is_some() {
2007 panic!("duplicate column type `{name}`");
2008 }
2009 }
2010
2011 let settings = Arc::new(settings);
2012 let asset_urls_arc = Arc::new(asset_urls(&assets));
2014 let active_skin = Arc::new(skin::resolve(
2015 &config.skin,
2016 &skins,
2017 &admin_path,
2018 &asset_urls_arc,
2019 ));
2020 let sprite_url: Arc<str> = Arc::from(
2021 asset_urls_arc
2022 .get(ICON_SPRITE)
2023 .map(|p| format!("{admin_path}/assets/{p}"))
2024 .unwrap_or_default()
2025 .as_str(),
2026 );
2027 let icons_arc = Arc::new(icon_set);
2028 let mut widget_regs = widgets::builtin();
2029 for reg in app_widgets {
2030 if let Some(at) = widget_regs.iter().position(|w| w.code() == reg.code()) {
2031 widget_regs[at] = reg;
2032 } else {
2033 widget_regs.push(reg);
2034 }
2035 }
2036 let cache = config
2037 .cache
2038 .clone()
2039 .unwrap_or_else(laterite_core::Cache::local);
2040 let queue = config
2041 .queue
2042 .clone()
2043 .unwrap_or_else(|| inline_queue(&db, cache.clone()));
2044 let schedule = config
2045 .schedule
2046 .clone()
2047 .unwrap_or_else(|| empty_schedule(&db, cache.clone(), queue.clone()));
2048 let state = AdminState {
2049 auth,
2050 parameters: laterite_core::parameters::Parameters::with_cache(db.clone(), cache.clone()),
2051 cache,
2052 queue,
2053 schedule,
2054 upload_extensions: Arc::new(upload_extensions(&config.upload_extensions)),
2055 db,
2056 nav: Arc::new(nav),
2057 nav_items: Arc::new(nav_items),
2058 settings: settings.clone(),
2059 permissions: Arc::new(permissions),
2060 admin_path: Arc::from(admin_path.as_str()),
2061 secure_cookie: config.secure_cookie,
2062 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
2063 icons: icons_arc.clone(),
2064 sprite_url: sprite_url.clone(),
2065 origin: Arc::from(config.origin.trim_end_matches('/')),
2066 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
2067 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
2068 catalogs,
2069 app_name,
2070 skin: active_skin,
2071 field_types: Arc::new(field_types),
2072 column_types: Arc::new(column_types),
2073 widgets: Arc::new(widget_regs),
2074 plugin_defined,
2075 storage_dir: Arc::from(config.storage_dir.trim_end_matches('/')),
2076 crypt,
2077 order: config.order.clone(),
2078 cookie_path: if app_public.iter().any(|r| r.is_site()) {
2079 Arc::from("/")
2080 } else {
2081 Arc::from(admin_path.as_str())
2082 },
2083 assets: Arc::new(assets),
2084 asset_urls: asset_urls_arc.clone(),
2085 pickers,
2086 palette: Arc::new(palette_sources),
2087 overrides: Arc::new(field::NoOverrides),
2088 };
2089
2090 let mut protected = Router::new().route(&admin_path, get(dashboard));
2091 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
2095 for resource in &resources {
2096 let (mounted, prepared) = mount_resource(
2097 resource,
2098 &state.field_types,
2099 &state.column_types,
2100 &permission_codes,
2101 &persisters,
2102 &app_listeners,
2103 );
2104 protected = protected.merge(mounted);
2105 if let Some(prepared) = prepared {
2106 let key = resource
2107 .base_path
2108 .strip_prefix(admin_path.as_str())
2109 .unwrap_or(&resource.base_path);
2110 forms.insert(key.to_string(), prepared);
2111 }
2112 }
2113 let forms: form::Forms = Arc::new(forms);
2114 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
2118 .plugin_defined
2119 .items::<laterite_media::DiskReg>()
2120 .into_iter()
2121 .find(|d| d.name == import::DISK)
2122 .map(|d| d.driver.clone())
2123 .unwrap_or_else(|| {
2124 Arc::new(laterite_media::LocalDisk::new(format!(
2125 "{}/{}",
2126 config.storage_dir.trim_end_matches('/'),
2127 import::DISK
2128 )))
2129 });
2130 for resource in &resources {
2131 let Some(link) = &resource.list.import else {
2132 continue;
2133 };
2134 let key = resource
2135 .base_path
2136 .strip_prefix(admin_path.as_str())
2137 .unwrap_or(&resource.base_path)
2138 .to_string();
2139 let prepared = forms.get(&key).unwrap_or_else(|| {
2140 panic!(
2141 "admin resource `{}` declares `import` but has no form to import through",
2142 resource.base_path
2143 )
2144 });
2145 let feature = import::ImportFeature {
2146 disk: private_disk.clone(),
2147 };
2148 let base = link.href.clone();
2149 let ctx = routes::RouteCtx::new(
2150 state.db.clone(),
2151 state.auth.clone(),
2152 state.parameters.clone(),
2153 state.queue.clone(),
2154 state.schedule.clone(),
2155 state.upload_extensions.clone(),
2156 state.field_types.clone(),
2157 state.overrides.clone(),
2158 state.crypt.clone(),
2159 &state.storage_dir,
2160 &base,
2161 &admin_path,
2162 &state.origin,
2163 state.plugin_defined.clone(),
2164 state.auth.events().clone(),
2165 forms.clone(),
2166 );
2167 let info = routes::ResourceInfo::new(
2168 key,
2169 resource.base_path.clone(),
2170 resource.nav_label.clone(),
2171 link.permission.clone(),
2172 form::FormHandle::new(state.db.clone(), state.cache.clone(), prepared.clone()),
2173 );
2174 protected = protected.merge(guard_with_permission(
2175 Router::new()
2176 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
2177 &link.permission,
2178 ));
2179 }
2180 for reg in &app_screens {
2184 let base = format!("{admin_path}{}", reg.base_path);
2185 let ctx = routes::RouteCtx::new(
2186 state.db.clone(),
2187 state.auth.clone(),
2188 state.parameters.clone(),
2189 state.queue.clone(),
2190 state.schedule.clone(),
2191 state.upload_extensions.clone(),
2192 state.field_types.clone(),
2193 state.overrides.clone(),
2194 state.crypt.clone(),
2195 &state.storage_dir,
2196 &base,
2197 &admin_path,
2198 &state.origin,
2199 state.plugin_defined.clone(),
2200 state.auth.events().clone(),
2201 forms.clone(),
2202 );
2203 protected = protected.merge(guard_with_permission(
2204 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
2205 ®.permission,
2206 ));
2207 }
2208
2209 protected = protected.merge(guard_with_permission(
2212 Router::new()
2213 .route(
2214 &format!("{admin_path}/roles/new"),
2215 get(roles::new_form).post(roles::create),
2216 )
2217 .route(
2218 &format!("{admin_path}/roles/{{id}}/edit"),
2219 get(roles::edit_form).post(roles::update),
2220 ),
2221 "backend.manage_roles",
2222 ));
2223 protected = protected.merge(guard_with_permission(
2226 Router::new()
2227 .route(
2228 &format!("{admin_path}/users/new"),
2229 get(users::new_form).post(users::create),
2230 )
2231 .route(
2232 &format!("{admin_path}/users/{{id}}/edit"),
2233 get(users::edit_form).post(users::update),
2234 )
2235 .route(
2236 &format!("{admin_path}/users/{{id}}/active"),
2237 post(users::set_active),
2238 )
2239 .route(
2240 &format!("{admin_path}/users/{{id}}/password"),
2241 post(users::reset_password),
2242 )
2243 .route(
2244 &format!("{admin_path}/users/{{id}}/unlock"),
2245 post(users::unlock),
2246 ),
2247 "backend.manage_users",
2248 ));
2249 protected = protected.merge(guard_with_permission(
2252 Router::new()
2253 .route(&format!("{admin_path}/plugins"), get(plugins::index))
2254 .route(
2255 &format!("{admin_path}/plugins/toggle"),
2256 post(plugins::toggle),
2257 ),
2258 plugins::MANAGE_PERMISSION,
2259 ));
2260 protected = protected.merge(guard_with_permission(
2262 Router::new()
2263 .route(&format!("{admin_path}/jobs"), get(jobs::index))
2264 .route(
2265 &format!("{admin_path}/jobs/retry-all"),
2266 post(jobs::retry_all),
2267 )
2268 .route(
2269 &format!("{admin_path}/jobs/forget-all"),
2270 post(jobs::forget_all),
2271 )
2272 .route(
2273 &format!("{admin_path}/jobs/{{id}}/retry"),
2274 post(jobs::retry),
2275 )
2276 .route(
2277 &format!("{admin_path}/jobs/{{id}}/forget"),
2278 post(jobs::forget),
2279 ),
2280 jobs::PERMISSION,
2281 ));
2282 protected = protected.merge(guard_with_permission(
2283 Router::new()
2284 .route(
2285 &format!("{admin_path}/scheduled-tasks"),
2286 get(scheduled::index),
2287 )
2288 .route(
2289 &format!("{admin_path}/scheduled-tasks/{{name}}/run"),
2290 post(scheduled::run_now),
2291 ),
2292 scheduled::PERMISSION,
2293 ));
2294 protected = protected
2295 .route(&format!("{admin_path}/settings"), get(settings_index))
2296 .route(
2297 &format!("{admin_path}/preferences"),
2298 get(preferences_form).post(preferences_update),
2299 )
2300 .route(
2301 &format!("{admin_path}/preferences/password"),
2302 post(password_update),
2303 )
2304 .route(
2305 &format!("{admin_path}/preferences/menu"),
2306 post(menu_order_update),
2307 )
2308 .route(
2309 &format!("{admin_path}/preferences/menu/moves"),
2310 post(menu_moves_update),
2311 )
2312 .route(
2313 &format!("{admin_path}/markdown/preview"),
2314 post(markdown_preview),
2315 )
2316 .route(&format!("{admin_path}/editor/menu"), get(editor_menu))
2317 .route(
2318 &format!("{admin_path}/dashboard/widget/{{code}}"),
2319 get(dashboard_widget),
2320 )
2321 .route(
2322 &format!("{admin_path}/dashboard/layout"),
2323 post(dashboard_layout_update),
2324 )
2325 .route(
2326 &format!("{admin_path}/dashboard/layout/reset"),
2327 post(dashboard_layout_reset),
2328 )
2329 .route(&format!("{admin_path}/palette"), get(palette::palette))
2330 .route(
2331 &format!("{admin_path}/preferences/sessions/revoke"),
2332 post(session_revoke),
2333 )
2334 .route(
2335 &format!("{admin_path}/preferences/sessions/revoke-others"),
2336 post(session_revoke_others),
2337 )
2338 .route(
2341 &format!("{admin_path}/pickers/{{source}}/search"),
2342 any(picker::search),
2343 )
2344 .route(
2345 &format!("{admin_path}/pickers/{{source}}/resolve"),
2346 any(picker::resolve),
2347 )
2348 .route(&format!("{admin_path}/logout"), post(logout));
2349
2350 for item in settings.iter().filter(|i| i.link.is_none()) {
2354 let path = format!("{admin_path}{}", item.route());
2355 let code = item.code.clone();
2356 let edit_code = code.clone();
2357 protected = protected.route(
2358 &path,
2359 get(
2360 move |State(state): State<AdminState>,
2361 Extension(shell): Extension<Shell>,
2362 Extension(user): Extension<AuthenticatedUser>| {
2363 let code = edit_code.clone();
2364 async move { settings_edit(state, shell, user, code).await }
2365 },
2366 )
2367 .post(
2368 move |State(state): State<AdminState>,
2369 Extension(shell): Extension<Shell>,
2370 Extension(user): Extension<AuthenticatedUser>,
2371 Extension(session): Extension<session::SessionHandle>,
2372 Form(data): Form<HashMap<String, String>>| {
2373 let code = code.clone();
2374 async move { settings_update(state, shell, user, session, code, data).await }
2375 },
2376 ),
2377 );
2378 }
2379
2380 let app = protected
2381 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
2382 .route(
2386 &format!("{admin_path}/login"),
2387 get(login_form).post(login_submit),
2388 )
2389 .route(
2390 &format!("{admin_path}/setup"),
2391 get(setup_form).post(setup_submit),
2392 )
2393 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
2394 .merge(mount_public(
2398 &app_public,
2399 &state.db,
2400 &state.auth,
2401 &state.parameters,
2402 &state.queue,
2403 &state.schedule,
2404 &state.upload_extensions,
2405 &state.field_types,
2406 &state.overrides,
2407 &state.crypt,
2408 &state.storage_dir,
2409 &admin_path,
2410 &state.origin,
2411 &state.plugin_defined,
2412 state.auth.events(),
2413 &forms,
2414 ));
2415 let app = match app_public.iter().find(|r| r.is_site()) {
2418 Some(site) => {
2419 let ctx = routes::RouteCtx::new(
2420 state.db.clone(),
2421 state.auth.clone(),
2422 state.parameters.clone(),
2423 state.queue.clone(),
2424 state.schedule.clone(),
2425 state.upload_extensions.clone(),
2426 state.field_types.clone(),
2427 state.overrides.clone(),
2428 state.crypt.clone(),
2429 &state.storage_dir,
2430 "/",
2431 &admin_path,
2432 &state.origin,
2433 state.plugin_defined.clone(),
2434 state.auth.events().clone(),
2435 forms.clone(),
2436 );
2437 app.fallback_service(site.route.mount(&ctx))
2438 }
2439 None => app.fallback(not_found_fallback),
2440 };
2441 let secure = state.secure_cookie;
2442 app
2443 .layer(middleware::from_fn_with_state(
2446 state.clone(),
2447 enforce_origin,
2448 ))
2449 .layer(middleware::from_fn_with_state(
2452 state.clone(),
2453 capture_client,
2454 ))
2455 .layer(
2460 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2461 header::CACHE_CONTROL,
2462 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
2463 ),
2464 )
2465 .layer(
2469 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2470 header::VARY,
2471 axum::http::HeaderValue::from_static("HX-Request"),
2472 ),
2473 )
2474 .layer(
2480 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2481 header::CONTENT_SECURITY_POLICY,
2482 axum::http::HeaderValue::from_static(CONTENT_SECURITY_POLICY),
2483 ),
2484 )
2485 .layer(
2486 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2487 header::X_CONTENT_TYPE_OPTIONS,
2488 axum::http::HeaderValue::from_static("nosniff"),
2489 ),
2490 )
2491 .layer(
2492 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2493 header::X_FRAME_OPTIONS,
2494 axum::http::HeaderValue::from_static("SAMEORIGIN"),
2495 ),
2496 )
2497 .layer(
2498 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2499 header::REFERRER_POLICY,
2500 axum::http::HeaderValue::from_static("same-origin"),
2501 ),
2502 )
2503 .layer(
2504 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2505 axum::http::HeaderName::from_static("permissions-policy"),
2506 axum::http::HeaderValue::from_static(
2507 "camera=(), microphone=(), geolocation=(), payment=()",
2508 ),
2509 ),
2510 )
2511 .layer(
2512 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2513 axum::http::HeaderName::from_static("cross-origin-opener-policy"),
2514 axum::http::HeaderValue::from_static("same-origin"),
2515 ),
2516 )
2517 .layer(
2520 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
2521 header::STRICT_TRANSPORT_SECURITY,
2522 if secure {
2523 Some(axum::http::HeaderValue::from_static(
2524 "max-age=31536000; includeSubDomains",
2525 ))
2526 } else {
2527 None
2528 },
2529 ),
2530 )
2531 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
2532 handle_panic,
2533 ))
2534 .with_state(state)
2535}
2536
2537pub const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self'; connect-src 'self' https:; frame-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none'";
2541
2542async fn not_found_fallback(
2545 State(state): State<AdminState>,
2546 method: axum::http::Method,
2547 uri: axum::http::Uri,
2548) -> AdminError {
2549 requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
2550 AdminError::NotFound
2551}
2552
2553async fn not_found_plain() -> AdminError {
2555 AdminError::NotFound
2556}
2557
2558fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
2560 let cause = err
2561 .downcast_ref::<&str>()
2562 .map(|s| s.to_string())
2563 .or_else(|| err.downcast_ref::<String>().cloned())
2564 .unwrap_or_else(|| "unknown panic".to_string());
2565 tracing::error!(panic = cause, "admin handler panicked");
2566 error::masked_500()
2567}
2568
2569pub fn normalize_path(path: &str) -> String {
2574 let trimmed = path.trim().trim_matches('/');
2575 if trimmed.is_empty() {
2576 "/admin".to_string()
2577 } else {
2578 format!("/{trimmed}")
2579 }
2580}
2581
2582#[allow(clippy::too_many_arguments)]
2586fn mount_public(
2587 regs: &[routes::PublicRouteReg],
2588 db: &Db,
2589 auth: &AuthService,
2590 parameters: &laterite_core::parameters::Parameters,
2591 queue: &laterite_core::Queue,
2592 schedule: &laterite_core::Schedule,
2593 upload_extensions: &Arc<Vec<String>>,
2594 field_types: &Arc<field::FieldRegistry>,
2595 overrides: &Arc<dyn field::OverrideResolver>,
2596 crypt: &Arc<laterite_core::Crypt>,
2597 storage_dir: &str,
2598 admin_path: &str,
2599 origin: &str,
2600 plugin_defined: &Arc<laterite_core::Registry>,
2601 events: &laterite_core::Events,
2602 forms: &form::Forms,
2603) -> Router<AdminState> {
2604 let mut router = Router::new();
2605 for reg in regs.iter().filter(|r| !r.is_site()) {
2607 let ctx = routes::RouteCtx::new(
2608 db.clone(),
2609 auth.clone(),
2610 parameters.clone(),
2611 queue.clone(),
2612 schedule.clone(),
2613 upload_extensions.clone(),
2614 field_types.clone(),
2615 overrides.clone(),
2616 crypt.clone(),
2617 storage_dir,
2618 ®.path,
2619 admin_path,
2620 origin,
2621 plugin_defined.clone(),
2622 events.clone(),
2623 forms.clone(),
2624 );
2625 router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
2630 }
2631 router
2632}
2633
2634pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
2637 let old = resource.base_path.clone();
2638 resource.base_path = base.to_string();
2639 if let Some(edit_base) = &mut resource.list.edit_base {
2640 if edit_base == &old {
2641 *edit_base = base.to_string();
2642 }
2643 }
2644 if let Some(form) = &mut resource.form {
2645 if form.base_path == old {
2646 form.base_path = base.to_string();
2647 }
2648 }
2649}
2650
2651fn prefix_resource(admin_path: &str, resource: &mut Resource) {
2652 resource.base_path = format!("{admin_path}{}", resource.base_path);
2653 if let Some(edit_base) = &mut resource.list.edit_base {
2654 *edit_base = format!("{admin_path}{edit_base}");
2655 }
2656 if let Some(form) = &mut resource.form {
2657 form.base_path = format!("{admin_path}{}", form.base_path);
2658 }
2659}
2660
2661#[derive(Debug, Clone)]
2665pub struct AdminAsset {
2666 pub mime: &'static str,
2667 pub bytes: std::borrow::Cow<'static, [u8]>,
2671}
2672
2673impl AdminAsset {
2674 pub fn new(mime: &'static str, bytes: &'static [u8]) -> Self {
2676 Self {
2677 mime,
2678 bytes: std::borrow::Cow::Borrowed(bytes),
2679 }
2680 }
2681
2682 pub fn css(bytes: &'static [u8]) -> Self {
2684 Self::new("text/css; charset=utf-8", bytes)
2685 }
2686
2687 pub fn js(bytes: &'static [u8]) -> Self {
2689 Self::new("text/javascript; charset=utf-8", bytes)
2690 }
2691}
2692
2693#[derive(Debug, Clone)]
2695pub struct AssetReg {
2696 pub key: &'static str,
2697 pub asset: AdminAsset,
2698}
2699
2700pub(crate) fn upload_extensions(configured: &[String]) -> Vec<String> {
2704 let listed: Vec<String> = configured
2705 .iter()
2706 .map(|e| e.trim().trim_start_matches('.').to_ascii_lowercase())
2707 .filter(|e| !e.is_empty())
2708 .collect();
2709 if listed.is_empty() {
2710 laterite_media::sanitize::DEFAULT_EXTENSIONS
2711 .iter()
2712 .map(|e| e.to_string())
2713 .collect()
2714 } else {
2715 listed
2716 }
2717}
2718
2719pub const SETUP_COMPLETED_AT: &str = "laterite.backend::setup.completed_at";
2724
2725pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
2728
2729pub(crate) type AssetUrls = HashMap<&'static str, String>;
2733
2734pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
2736 registry
2737 .iter()
2738 .map(|(&key, asset)| {
2739 (
2740 key,
2741 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
2742 )
2743 })
2744 .collect()
2745}
2746
2747pub(crate) fn page_assets(
2754 keys: &[&str],
2755 base: &str,
2756 registry: &AssetRegistry,
2757 urls: &AssetUrls,
2758) -> Vec<PageAsset> {
2759 let mut seen = std::collections::HashSet::new();
2760 let mut out = Vec::new();
2761 for &key in keys {
2762 if !seen.insert(key) {
2763 continue;
2764 }
2765 match (registry.get(key), urls.get(key)) {
2766 (Some(asset), Some(path)) => out.push(PageAsset {
2767 url: format!("{base}/assets/{path}"),
2768 css: asset.mime.contains("css"),
2769 }),
2770 _ => debug_assert!(false, "asset key `{key}` is not registered"),
2771 }
2772 }
2773 out
2774}
2775
2776pub const CODE_EDITOR_ASSETS: &[&str] = &[
2782 "vendor/codeeditor.js",
2783 "fields/codeeditor.js",
2784 "fields/codeeditor.css",
2785];
2786
2787pub const RICH_EDITOR_ASSETS: &[&str] = &[
2791 "vendor/richeditor.js",
2792 "vendor/richeditor.css",
2793 "fields/richeditor.js",
2794 "fields/richeditor.css",
2795];
2796
2797pub const MARKDOWN_ASSETS: &[&str] = &[
2801 "vendor/codeeditor.js",
2802 "fields/codeeditor.css",
2803 "fields/markdown.js",
2804 "fields/markdown.css",
2805];
2806
2807pub(crate) fn builtin_assets() -> AssetRegistry {
2818 let font = |bytes: &'static [u8]| AdminAsset {
2819 mime: "font/woff2",
2820 bytes: std::borrow::Cow::Borrowed(bytes),
2821 };
2822 HashMap::from([
2823 (
2824 "laterite.css",
2825 AdminAsset {
2826 mime: "text/css; charset=utf-8",
2827 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
2828 },
2829 ),
2830 (
2831 "laterite.js",
2832 AdminAsset {
2833 mime: "text/javascript; charset=utf-8",
2834 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
2835 },
2836 ),
2837 (
2838 skin::COMPACT_STYLESHEET,
2839 AdminAsset::css(include_bytes!("../assets/skins/compact.css")),
2840 ),
2841 (
2842 "theme-boot.js",
2843 AdminAsset {
2844 mime: "text/javascript; charset=utf-8",
2845 bytes: std::borrow::Cow::Borrowed(laterite_core::theme::BOOT_JS.as_bytes()),
2846 },
2847 ),
2848 (
2849 "vendor/htmx.min.js",
2850 AdminAsset {
2851 mime: "text/javascript; charset=utf-8",
2852 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
2853 },
2854 ),
2855 (
2856 "vendor/richeditor.js",
2857 AdminAsset {
2858 mime: "text/javascript; charset=utf-8",
2859 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/richeditor.js")),
2860 },
2861 ),
2862 (
2863 "vendor/richeditor.css",
2864 AdminAsset {
2865 mime: "text/css; charset=utf-8",
2866 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2867 "../assets/vendor/richeditor.css"
2868 )),
2869 },
2870 ),
2871 (
2872 "vendor/codeeditor.js",
2873 AdminAsset {
2874 mime: "text/javascript; charset=utf-8",
2875 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/codeeditor.js")),
2876 },
2877 ),
2878 (
2879 "fields/richeditor.js",
2880 AdminAsset {
2881 mime: "text/javascript; charset=utf-8",
2882 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/richeditor.js")),
2883 },
2884 ),
2885 (
2886 "fields/richeditor.css",
2887 AdminAsset {
2888 mime: "text/css; charset=utf-8",
2889 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2890 "../assets/fields/richeditor.css"
2891 )),
2892 },
2893 ),
2894 (
2895 "fields/markdown.js",
2896 AdminAsset {
2897 mime: "text/javascript; charset=utf-8",
2898 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/markdown.js")),
2899 },
2900 ),
2901 (
2902 "fields/markdown.css",
2903 AdminAsset {
2904 mime: "text/css; charset=utf-8",
2905 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/markdown.css")),
2906 },
2907 ),
2908 (
2909 "fields/codeeditor.js",
2910 AdminAsset {
2911 mime: "text/javascript; charset=utf-8",
2912 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/codeeditor.js")),
2913 },
2914 ),
2915 (
2916 "fields/codeeditor.css",
2917 AdminAsset {
2918 mime: "text/css; charset=utf-8",
2919 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2920 "../assets/fields/codeeditor.css"
2921 )),
2922 },
2923 ),
2924 (
2925 "fields/ref-picker.js",
2926 AdminAsset {
2927 mime: "text/javascript; charset=utf-8",
2928 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
2929 },
2930 ),
2931 (
2932 "fields/ref-picker.css",
2933 AdminAsset {
2934 mime: "text/css; charset=utf-8",
2935 bytes: std::borrow::Cow::Borrowed(include_bytes!(
2936 "../assets/fields/ref-picker.css"
2937 )),
2938 },
2939 ),
2940 (
2941 "mark.svg",
2942 AdminAsset {
2943 mime: "image/svg+xml",
2944 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
2945 },
2946 ),
2947 (
2948 "mark.png",
2949 AdminAsset {
2950 mime: "image/png",
2951 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
2952 },
2953 ),
2954 (
2955 "fonts/space-grotesk-500.woff2",
2956 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
2957 ),
2958 (
2959 "fonts/space-grotesk-600.woff2",
2960 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
2961 ),
2962 (
2963 "fonts/space-grotesk-700.woff2",
2964 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
2965 ),
2966 (
2967 "fonts/ibm-plex-sans-400.woff2",
2968 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
2969 ),
2970 (
2971 "fonts/ibm-plex-sans-600.woff2",
2972 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
2973 ),
2974 (
2975 "fonts/ibm-plex-mono-400.woff2",
2976 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
2977 ),
2978 (
2979 "fonts/ibm-plex-mono-600.woff2",
2980 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
2981 ),
2982 ])
2983}
2984
2985async fn serve_asset(
2987 State(state): State<AdminState>,
2988 headers: HeaderMap,
2989 Path(path): Path<String>,
2990) -> Response {
2991 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
2995 let Some(asset) = state.assets.get(key.as_str()) else {
2996 return not_found();
2997 };
2998 let policy = if fingerprinted {
2999 http_cache::IMMUTABLE
3000 } else {
3001 http_cache::REVALIDATE
3002 };
3003 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
3004}
3005
3006fn mount_resource(
3011 resource: &Resource,
3012 field_types: &field::FieldRegistry,
3013 column_types: &list::ColumnRegistry,
3014 permission_codes: &std::collections::HashSet<String>,
3015 persisters: &persist::PersisterRegistry,
3016 listeners: &[laterite_core::ModelListenerReg],
3017) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
3018 list::check(&resource.list, column_types, permission_codes)
3022 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3023 if resource.form.is_some() && resource.permission.is_none() {
3028 panic!(
3029 "admin resource `{}` has a create/edit form but no permission; a write \
3030 resource must declare a permission",
3031 resource.base_path
3032 );
3033 }
3034 let base = resource.base_path.clone();
3035 let list_path = base.clone();
3036 let mut list_cfg = resource.list.clone();
3037 if resource.list.columns.iter().any(|c| c.editable) {
3040 let Some(form_cfg) = resource.form.clone() else {
3041 panic!(
3042 "admin resource `{}` has an editable column but no form to edit through",
3043 resource.base_path
3044 );
3045 };
3046 for column in resource.list.columns.iter().filter(|c| c.editable) {
3047 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
3048 panic!(
3049 "admin resource `{}`: column `{}` is editable but the form has no such field",
3050 resource.base_path, column.field
3051 );
3052 }
3053 }
3054 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
3055 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3056 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
3057 }
3058 let cell_cfg = list_cfg.clone();
3059 let mut router = Router::new().route(
3060 &base,
3061 get(
3062 move |State(state): State<AdminState>,
3063 Extension(shell): Extension<Shell>,
3064 Query(params): Query<list::ListParams>,
3065 Query(raw): Query<std::collections::HashMap<String, String>>,
3066 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
3067 Extension(user): Extension<AuthenticatedUser>,
3068 Extension(session): Extension<session::SessionHandle>,
3069 headers: axum::http::HeaderMap| {
3070 let cfg = list_cfg.clone();
3071 let path = list_path.clone();
3072 async move {
3073 list::handle_remembering(
3074 &state,
3075 &cfg,
3076 &path,
3077 params,
3078 &raw,
3079 &user,
3080 shell,
3081 &headers,
3082 query_string.as_deref(),
3083 Some(&session),
3084 )
3085 .await
3086 }
3087 },
3088 ),
3089 );
3090
3091 if cell_cfg.editor.is_some() {
3092 let (cfg, list_path) = (cell_cfg, base.clone());
3093 router = router.route(
3094 &format!("{base}/{{id}}/cell"),
3095 post(
3096 move |State(state): State<AdminState>,
3097 Extension(shell): Extension<Shell>,
3098 Extension(user): Extension<AuthenticatedUser>,
3099 Extension(session): Extension<session::SessionHandle>,
3100 Path(id): Path<String>,
3101 headers: axum::http::HeaderMap,
3102 Form(data): Form<HashMap<String, String>>| {
3103 let (cfg, path) = (cfg.clone(), list_path.clone());
3104 async move {
3105 list::cell_handler(
3106 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
3107 )
3108 .await
3109 }
3110 },
3111 ),
3112 );
3113 }
3114
3115 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
3118 if resource.list.exportable {
3119 router = router.route(
3120 &format!("{base}/export"),
3121 get(
3122 move |state: State<AdminState>,
3123 Query(raw): Query<std::collections::HashMap<String, String>>,
3124 Extension(user): Extension<AuthenticatedUser>,
3125 Extension(shell): Extension<Shell>,
3126 Extension(session): Extension<session::SessionHandle>| {
3127 let cfg = export_cfg.clone();
3128 let path = export_path.clone();
3129 async move {
3130 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
3131 }
3132 },
3133 ),
3134 );
3135 }
3136
3137 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
3138 router = router.route(
3139 &format!("{base}/columns"),
3140 post(
3141 move |State(state): State<AdminState>,
3142 Extension(user): Extension<AuthenticatedUser>,
3143 Extension(session): Extension<session::SessionHandle>,
3144 headers: axum::http::HeaderMap,
3145 Form(pairs): Form<Vec<(String, String)>>| {
3146 let cfg = columns_cfg.clone();
3147 let path = columns_path.clone();
3148 async move {
3149 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
3150 }
3151 },
3152 ),
3153 );
3154
3155 if resource.list.deletable {
3156 let persister: Arc<dyn persist::Persister> =
3160 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
3161 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
3162 panic!(
3163 "admin resource `{}`: unregistered persister `{key}`",
3164 resource.base_path
3165 )
3166 }),
3167 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
3168 };
3169 let ctx = Arc::new(bulk::BulkContext {
3170 base_path: resource.base_path.clone(),
3171 entity: resource.list.entity.clone(),
3172 persister,
3173 listeners: listeners
3174 .iter()
3175 .filter(|reg| reg.matches(&resource.list.entity))
3176 .map(|reg| reg.listener.clone())
3177 .collect(),
3178 });
3179 router =
3180 router.route(
3181 &format!("{base}/delete"),
3182 post(
3183 move |state: State<AdminState>,
3184 user: Extension<AuthenticatedUser>,
3185 session: Extension<session::SessionHandle>,
3186 headers: axum::http::HeaderMap,
3187 form: Form<Vec<(String, String)>>| {
3188 let ctx = ctx.clone();
3189 async move {
3190 bulk::delete_handler(state, user, session, ctx, headers, form).await
3191 }
3192 },
3193 ),
3194 );
3195 }
3196
3197 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
3200 let tree = resource.list.tree.clone().unwrap_or_default();
3201 let mut move_cfg = form::FormConfig::new(
3202 resource.list.entity.clone(),
3203 laterite_core::t!("Move to"),
3204 base.clone(),
3205 resource.list.id_field.clone(),
3206 vec![form::FormField::reference(
3207 &tree.parent_key,
3208 laterite_core::t!("New parent"),
3209 &source,
3210 )
3211 .help(laterite_core::t!("Leave empty for the top level."))],
3212 );
3213 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
3214 move_cfg = move_cfg.persist(persist);
3215 }
3216 move_cfg.done_path = Some(base.clone());
3217 let move_form = Arc::new(
3218 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
3219 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
3220 );
3221 let (cfg, list_path) = (resource.list.clone(), base.clone());
3222 let (form_get, form_post) = (move_form.clone(), move_form);
3223 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
3224 router = router.route(
3225 &format!("{base}/{{id}}/move"),
3226 get(
3227 move |State(state): State<AdminState>,
3228 Extension(shell): Extension<Shell>,
3229 Path(id): Path<String>| {
3230 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
3231 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
3232 },
3233 )
3234 .post(
3235 move |State(state): State<AdminState>,
3236 Extension(shell): Extension<Shell>,
3237 Extension(user): Extension<AuthenticatedUser>,
3238 Extension(session): Extension<session::SessionHandle>,
3239 Path(id): Path<String>,
3240 headers: axum::http::HeaderMap,
3241 Form(data): Form<HashMap<String, String>>| {
3242 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
3243 async move {
3244 list::move_record(
3245 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
3246 )
3247 .await
3248 }
3249 },
3250 ),
3251 );
3252 }
3253
3254 if resource.list.reorder.is_some() {
3255 let (cfg, list_path) = (resource.list.clone(), base.clone());
3256 router = router.route(
3257 &format!("{base}/reorder"),
3258 post(
3259 move |State(state): State<AdminState>,
3260 Extension(shell): Extension<Shell>,
3261 Extension(user): Extension<AuthenticatedUser>,
3262 Extension(session): Extension<session::SessionHandle>,
3263 headers: axum::http::HeaderMap,
3264 Form(pairs): Form<Vec<(String, String)>>| {
3265 let cfg = cfg.clone();
3266 let path = list_path.clone();
3267 async move {
3268 list::reorder_handler(
3269 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
3270 &headers, &pairs,
3271 )
3272 .await
3273 }
3274 },
3275 ),
3276 );
3277 }
3278
3279 if !resource.relations.is_empty() && resource.form.is_none() {
3282 panic!(
3283 "admin resource `{}` declares relations but has no form to show them on",
3284 resource.base_path
3285 );
3286 }
3287 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
3288 for rel in &resource.relations {
3289 let prepared = relation::PreparedRelation::prepare(
3290 resource,
3291 rel.clone(),
3292 field_types,
3293 column_types,
3294 permission_codes,
3295 persisters,
3296 listeners,
3297 )
3298 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3299 relations.push(Arc::new(prepared));
3300 }
3301 let relations = Arc::new(relations);
3302
3303 let mut prepared_form = None;
3304 if let Some(form_cfg) = resource.form.clone() {
3305 let mut prepared =
3308 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
3309 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
3310 prepared.stay = !relations.is_empty();
3312 let prepared = Arc::new(prepared);
3313 prepared_form = Some(prepared.clone());
3314 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
3315 let relations_for_new = relations.clone();
3316 router = router.route(
3317 &format!("{base}/new"),
3318 get(
3319 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
3320 let pf = new_pf.clone();
3321 let relations = relations_for_new.clone();
3322 async move {
3323 let pending = relations
3324 .iter()
3325 .map(|r| shell.tt(&r.config.title))
3326 .collect();
3327 form::new_form(&state, &pf, shell, pending)
3328 }
3329 },
3330 )
3331 .post(
3332 move |State(state): State<AdminState>,
3333 Extension(shell): Extension<Shell>,
3334 Extension(user): Extension<AuthenticatedUser>,
3335 Extension(session): Extension<session::SessionHandle>,
3336 headers: axum::http::HeaderMap,
3337 Form(data): Form<HashMap<String, String>>| {
3338 let pf = create_pf.clone();
3339 async move {
3340 form::create(&state, &pf, data, shell, &user, &session, &headers).await
3341 }
3342 },
3343 ),
3344 );
3345
3346 let reveal_pf = prepared.clone();
3347 router = router.route(
3348 &format!("{base}/{{id}}/reveal/{{field}}"),
3349 get(
3350 move |State(state): State<AdminState>,
3351 Extension(user): Extension<AuthenticatedUser>,
3352 Path((id, field)): Path<(String, String)>| {
3353 let pf = reveal_pf.clone();
3354 async move { form::reveal(&state, &pf, &id, &field, &user).await }
3355 },
3356 ),
3357 );
3358
3359 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
3360 let relations_for_edit = relations.clone();
3361 router = router.route(
3362 &format!("{base}/{{id}}/edit"),
3363 get(
3364 move |State(state): State<AdminState>,
3365 Extension(shell): Extension<Shell>,
3366 Extension(user): Extension<AuthenticatedUser>,
3367 Path(id): Path<String>| {
3368 let pf = edit_pf.clone();
3369 let relations = relations_for_edit.clone();
3370 async move {
3371 let mut sections = Vec::new();
3372 for rel in relations.iter() {
3373 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
3374 sections.push(section);
3375 }
3376 }
3377 form::edit_form(&state, &pf, id, shell, sections).await
3378 }
3379 },
3380 )
3381 .post(
3382 move |State(state): State<AdminState>,
3383 Extension(shell): Extension<Shell>,
3384 Extension(user): Extension<AuthenticatedUser>,
3385 Extension(session): Extension<session::SessionHandle>,
3386 Path(id): Path<String>,
3387 headers: axum::http::HeaderMap,
3388 Form(data): Form<HashMap<String, String>>| {
3389 let pf = update_pf.clone();
3390 async move {
3391 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
3392 }
3393 },
3394 ),
3395 );
3396 }
3397
3398 for rel in relations.iter() {
3399 let name = rel.config.name.clone();
3400 let mut routes: Router<AdminState> = Router::new();
3401 let r = rel.clone();
3402 routes =
3403 routes.route(
3404 &format!("{base}/{{id}}/{name}"),
3405 get(
3406 move |state: State<AdminState>,
3407 shell: Extension<Shell>,
3408 user: Extension<AuthenticatedUser>,
3409 id: Path<String>,
3410 params: Query<list::ListParams>,
3411 raw: Query<HashMap<String, String>>,
3412 headers: axum::http::HeaderMap| {
3413 let r = r.clone();
3414 async move {
3415 relation::index(state, shell, user, id, params, raw, headers, r).await
3416 }
3417 },
3418 ),
3419 );
3420 let (r_new, r_create) = (rel.clone(), rel.clone());
3421 routes = routes.route(
3422 &format!("{base}/{{id}}/{name}/new"),
3423 get(
3424 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
3425 let r = r_new.clone();
3426 async move { relation::new_form(state, shell, id, r).await }
3427 },
3428 )
3429 .post(
3430 move |state: State<AdminState>,
3431 shell: Extension<Shell>,
3432 user: Extension<AuthenticatedUser>,
3433 session: Extension<session::SessionHandle>,
3434 id: Path<String>,
3435 headers: axum::http::HeaderMap,
3436 data: Form<HashMap<String, String>>| {
3437 let r = r_create.clone();
3438 async move {
3439 relation::create(state, shell, user, session, id, headers, data, r).await
3440 }
3441 },
3442 ),
3443 );
3444 let (r_edit, r_update) = (rel.clone(), rel.clone());
3445 routes = routes.route(
3446 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
3447 get(
3448 move |state: State<AdminState>,
3449 shell: Extension<Shell>,
3450 ids: Path<(String, String)>| {
3451 let r = r_edit.clone();
3452 async move { relation::edit_form(state, shell, ids, r).await }
3453 },
3454 )
3455 .post(
3456 move |state: State<AdminState>,
3457 shell: Extension<Shell>,
3458 user: Extension<AuthenticatedUser>,
3459 session: Extension<session::SessionHandle>,
3460 ids: Path<(String, String)>,
3461 headers: axum::http::HeaderMap,
3462 data: Form<HashMap<String, String>>| {
3463 let r = r_update.clone();
3464 async move {
3465 relation::update(state, shell, user, session, ids, headers, data, r).await
3466 }
3467 },
3468 ),
3469 );
3470 let r_delete = rel.clone();
3471 routes = routes.route(
3472 &format!("{base}/{{id}}/{name}/delete"),
3473 post(
3474 move |state: State<AdminState>,
3475 shell: Extension<Shell>,
3476 user: Extension<AuthenticatedUser>,
3477 session: Extension<session::SessionHandle>,
3478 id: Path<String>,
3479 headers: axum::http::HeaderMap,
3480 pairs: Form<Vec<(String, String)>>| {
3481 let r = r_delete.clone();
3482 async move {
3483 relation::delete(state, shell, user, session, id, headers, pairs, r).await
3484 }
3485 },
3486 ),
3487 );
3488 if rel.reorderable() {
3489 let r_reorder = rel.clone();
3490 routes = routes.route(
3491 &format!("{base}/{{id}}/{name}/reorder"),
3492 post(
3493 move |state: State<AdminState>,
3494 shell: Extension<Shell>,
3495 user: Extension<AuthenticatedUser>,
3496 session: Extension<session::SessionHandle>,
3497 id: Path<String>,
3498 headers: axum::http::HeaderMap,
3499 pairs: Form<Vec<(String, String)>>| {
3500 let r = r_reorder.clone();
3501 async move {
3502 relation::reorder(state, shell, user, session, id, headers, pairs, r)
3503 .await
3504 }
3505 },
3506 ),
3507 );
3508 }
3509 if let Some(permission) = &rel.config.permission {
3510 routes = guard_with_permission(routes, permission);
3511 }
3512 router = router.merge(routes);
3513 }
3514
3515 if let Some(permission) = &resource.permission {
3517 router = guard_with_permission(router, permission);
3518 }
3519 (router, prepared_form)
3520}
3521
3522fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
3527 let needed: Arc<str> = Arc::from(permission);
3528 router.route_layer(middleware::from_fn(
3529 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
3530 let needed = needed.clone();
3531 async move {
3532 if user.allows(&needed) {
3533 next.run(req).await
3534 } else {
3535 forbidden()
3536 }
3537 }
3538 },
3539 ))
3540}
3541
3542async fn require_auth(
3548 State(state): State<AdminState>,
3549 jar: CookieJar,
3550 request: Request,
3551 next: Next,
3552) -> Response {
3553 let login = format!("{}/login", state.admin_path);
3554 let client_ctx = request
3555 .extensions()
3556 .get::<RequestContext>()
3557 .cloned()
3558 .unwrap_or_default();
3559 let mut ended: Option<laterite_auth::SessionEnd> = None;
3564 let live = match jar.get(SESSION_COOKIE) {
3565 Some(cookie) => {
3566 let token = cookie.value().to_string();
3567 match state.auth.resolve_session(&token).await {
3568 Ok(resolved) => Some((token, resolved)),
3569 Err(_) => {
3570 ended = state.auth.session_end(&token).await.ok().flatten();
3571 None
3572 }
3573 }
3574 }
3575 None => None,
3576 };
3577 let htmx = request.headers().contains_key("hx-request");
3578 let (token, resolved, recalled) = match live {
3579 Some((token, resolved)) => (token, resolved, None),
3580 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
3583 return signed_out(&state, jar, &login, ended, htmx);
3584 }
3585 None => match recall(&state, &jar, &client_ctx).await {
3586 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
3587 None => {
3588 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3592 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
3593 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
3594 }
3595 }
3596 return signed_out(&state, jar, &login, ended, htmx);
3597 }
3598 },
3599 };
3600 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
3601
3602 let mut request = request;
3607 let safe = session::is_safe_method(request.method());
3608 let mut deferred: Option<session::CsrfPending> = None;
3610 if !safe {
3611 let (parts, body) = request.into_parts();
3612 let (body, submitted) = if session::is_multipart(&parts.headers) {
3619 let token = session::header_token(&parts.headers)
3620 .or_else(|| session::query_token(parts.uri.query()));
3621 if token.is_none() {
3626 deferred = Some(session::CsrfPending::default());
3627 }
3628 (body, token)
3629 } else {
3630 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
3631 .await
3632 .unwrap_or_default();
3633 let token = session::submitted_token(&parts.headers, &bytes);
3634 (Body::from(bytes), token)
3635 };
3636 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
3637 {
3638 tracing::warn!(
3639 user_id = resolved.identity.user.id,
3640 "admin CSRF check failed"
3641 );
3642 return error::csrf_rejected();
3643 }
3644 request = Request::from_parts(parts, body);
3645 }
3646
3647 let user = resolved.identity;
3648 let acting_user_id = user.user.id;
3650 let path = request.uri().path().to_string();
3651 let preferences = format!("{}/preferences", state.admin_path);
3654 if !path.starts_with(&preferences)
3655 && path != format!("{}/logout", state.admin_path)
3656 && state
3657 .auth
3658 .must_change_password(user.user.id)
3659 .await
3660 .unwrap_or(false)
3661 {
3662 let to = format!("{preferences}#password");
3663 let response = if htmx {
3664 (
3665 [("HX-Redirect", to.as_str())],
3666 axum::http::StatusCode::NO_CONTENT,
3667 )
3668 .into_response()
3669 } else {
3670 Redirect::to(&to).into_response()
3671 };
3672 return with_recall_cookies(response, recalled, jar, token, &state);
3673 }
3674 let flash = if safe {
3677 handle.take_flash()
3678 } else {
3679 Vec::new()
3680 };
3681 let accept_language = request
3685 .headers()
3686 .get("accept-language")
3687 .and_then(|v| v.to_str().ok());
3688 let serveable = state.catalogs.locales();
3689 let chain = resolve_locale_chain(
3690 user.user.locale.as_deref(),
3691 accept_language,
3692 &state.default_locale,
3693 &serveable,
3694 );
3695 let i18n = Translator::with_chain(chain, state.catalogs.clone());
3696 let translator = i18n.clone();
3699 let own = OwnMenu::load(&state, user.user.id).await;
3701 let (own_nav, own_settings) = own_tree(&state, &own);
3702 let (visible_nav, sidebar, active_nav) = resolve_nav_context(
3703 &own_nav,
3704 &own_settings,
3705 &state.admin_path,
3706 &user.permissions,
3707 own.nav_order.as_deref(),
3708 &path,
3709 &i18n,
3710 icons::Icons::new(&state.icons, &state.sprite_url),
3711 &state.order,
3712 own.settings_order.as_deref(),
3713 );
3714 let brand = state.brand().await;
3715 let shell = Shell::new(
3716 &state.admin_path,
3717 brand,
3718 state.skin.clone(),
3719 &visible_nav,
3720 &user,
3721 state.timezone,
3722 sidebar,
3723 active_nav.as_deref(),
3724 handle.csrf_token(),
3725 flash,
3726 i18n,
3727 state.asset_urls.clone(),
3728 state.origin.to_string(),
3729 state.icons.clone(),
3730 state.sprite_url.clone(),
3731 );
3732 request.extensions_mut().insert(user);
3733 request.extensions_mut().insert(shell);
3734 request.extensions_mut().insert(handle.clone());
3735 if let Some(pending) = &deferred {
3737 request.extensions_mut().insert(pending.clone());
3738 }
3739 let mut response = next.run(request).await;
3740
3741 if let Some(pending) = &deferred {
3746 if !pending.was_satisfied() {
3747 tracing::error!(
3748 user_id = acting_user_id,
3749 "a multipart route returned without verifying its request token"
3750 );
3751 return error::csrf_rejected();
3752 }
3753 }
3754
3755 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
3759 response = error::localized_error(meta.kind, &translator);
3760 }
3761
3762 if let Some(blob) = handle.dirty_blob() {
3765 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
3766 tracing::error!(error = %e, "persisting admin session failed");
3767 }
3768 }
3769 with_recall_cookies(response, recalled, jar, token, &state)
3770}
3771
3772fn with_recall_cookies(
3776 response: Response,
3777 recalled: Option<laterite_auth::RememberCredential>,
3778 jar: CookieJar,
3779 token: String,
3780 state: &AdminState,
3781) -> Response {
3782 match recalled {
3783 Some(credential) => {
3784 let jar = jar
3785 .add(session_cookie(
3786 token,
3787 &state.cookie_path,
3788 state.secure_cookie,
3789 ))
3790 .add(remember_cookie(
3791 credential,
3792 &state.cookie_path,
3793 state.secure_cookie,
3794 ));
3795 (jar, response).into_response()
3796 }
3797 None => response,
3798 }
3799}
3800
3801async fn recall(
3805 state: &AdminState,
3806 jar: &CookieJar,
3807 ctx: &RequestContext,
3808) -> Option<(
3809 String,
3810 laterite_auth::ResolvedSession,
3811 laterite_auth::RememberCredential,
3812)> {
3813 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
3814 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
3815 let token = recalled.session.token.clone();
3816 let resolved = state.auth.resolve_session(&token).await.ok()?;
3817 Some((token, resolved, recalled.remember))
3818}
3819
3820const MAX_FORM_BYTES: usize = 1024 * 1024;
3823
3824async fn capture_client(
3832 State(state): State<AdminState>,
3833 mut request: Request,
3834 next: Next,
3835) -> Response {
3836 let peer = request
3837 .extensions()
3838 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
3839 .map(|info| info.0);
3840 let headers = request.headers();
3841 let ctx = RequestContext {
3842 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
3843 user_agent: clientip::user_agent(headers),
3844 };
3845 request.extensions_mut().insert(ctx);
3846 next.run(request).await
3847}
3848
3849async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
3850 if !session::is_safe_method(request.method())
3851 && !session::origin_ok(request.headers(), &state.origin)
3852 {
3853 tracing::warn!(
3854 path = request.uri().path(),
3855 "admin CSRF origin check failed"
3856 );
3857 return error::csrf_rejected();
3858 }
3859 next.run(request).await
3860}
3861
3862#[derive(Deserialize)]
3863struct LoginQuery {
3864 ended: Option<String>,
3865}
3866
3867async fn login_form(
3868 State(state): State<AdminState>,
3869 headers: HeaderMap,
3870 Query(query): Query<LoginQuery>,
3871) -> Response {
3872 match state.auth.has_any_operator().await {
3874 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
3875 Ok(true) => {
3876 let i18n = pre_auth_translator(&state, &headers);
3877 let notice = query
3878 .ended
3879 .as_deref()
3880 .and_then(end_notice)
3881 .map(|text| i18n.t(&text));
3882 render(LoginTemplate {
3883 base: state.admin_path.to_string(),
3884 asset_urls: state.asset_urls.clone(),
3885 brand: state.brand().await.name.clone(),
3886 look: state.brand().await,
3887 skin: state.skin.clone(),
3888 error: None,
3889 notice,
3890 i18n,
3891 })
3892 }
3893 Err(_) => render_error(),
3894 }
3895}
3896
3897#[derive(Deserialize)]
3898struct LoginForm {
3899 username: String,
3900 password: String,
3901 #[serde(default)]
3903 remember: Option<String>,
3904}
3905
3906async fn login_submit(
3907 State(state): State<AdminState>,
3908 jar: CookieJar,
3909 headers: HeaderMap,
3910 Extension(ctx): Extension<RequestContext>,
3911 Form(form): Form<LoginForm>,
3912) -> Response {
3913 match state
3914 .auth
3915 .authenticate(&form.username, &form.password, &ctx)
3916 .await
3917 {
3918 Ok(session) => {
3919 let wants_remember = form.remember.is_some();
3920 let user_id = session.user_id;
3921 let mut jar = jar.add(session_cookie(
3922 session.token,
3923 &state.cookie_path,
3924 state.secure_cookie,
3925 ));
3926 if wants_remember {
3927 match state.auth.issue_remember(user_id).await {
3928 Ok(credential) => {
3929 jar = jar.add(remember_cookie(
3930 credential,
3931 &state.cookie_path,
3932 state.secure_cookie,
3933 ));
3934 }
3935 Err(e) => {
3937 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
3938 }
3939 }
3940 }
3941 (jar, Redirect::to(&state.admin_path)).into_response()
3942 }
3943 Err(_) => {
3944 let i18n = pre_auth_translator(&state, &headers);
3945 render(LoginTemplate {
3946 base: state.admin_path.to_string(),
3947 asset_urls: state.asset_urls.clone(),
3948 brand: state.brand().await.name.clone(),
3949 look: state.brand().await,
3950 skin: state.skin.clone(),
3951 error: Some(i18n.t(&t!("Invalid username or password."))),
3952 notice: None,
3953 i18n,
3954 })
3955 }
3956 }
3957}
3958
3959fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
3969 Cookie::build((SESSION_COOKIE, token))
3970 .path(admin_path.to_string())
3971 .http_only(true)
3972 .secure(secure)
3973 .same_site(SameSite::Lax)
3974 .build()
3975}
3976
3977fn remember_cookie(
3980 credential: laterite_auth::RememberCredential,
3981 admin_path: &str,
3982 secure: bool,
3983) -> Cookie<'static> {
3984 let seconds = (credential.expires_at - chrono::Utc::now())
3985 .num_seconds()
3986 .max(0);
3987 Cookie::build((REMEMBER_COOKIE, credential.cookie))
3988 .path(admin_path.to_string())
3989 .http_only(true)
3990 .secure(secure)
3991 .same_site(SameSite::Lax)
3992 .max_age(cookie::time::Duration::seconds(seconds))
3993 .build()
3994}
3995
3996fn signed_out(
4003 state: &AdminState,
4004 jar: CookieJar,
4005 login: &str,
4006 ended: Option<laterite_auth::SessionEnd>,
4007 htmx: bool,
4008) -> Response {
4009 let to = match ended.map(laterite_auth::SessionEnd::code) {
4010 Some(code) => format!("{login}?ended={code}"),
4011 None => login.to_string(),
4012 };
4013 let jar = jar
4014 .remove(
4015 Cookie::build((SESSION_COOKIE, ""))
4016 .path(state.cookie_path.to_string())
4017 .build(),
4018 )
4019 .remove(remember_removal(&state.cookie_path));
4020 if htmx {
4021 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
4022 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
4023 response.headers_mut().insert("hx-redirect", value);
4024 }
4025 return response;
4026 }
4027 (jar, Redirect::to(&to)).into_response()
4028}
4029
4030fn end_notice(code: &str) -> Option<Text> {
4033 Some(match code {
4034 "password_changed" => {
4035 t!("Your password was changed, so you were signed out. Sign in with the new one.")
4036 }
4037 "deactivated" => t!("This account was deactivated."),
4038 "signed_out_elsewhere" => t!("You were signed out from another device."),
4039 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
4040 _ => return None,
4041 })
4042}
4043
4044fn remember_removal(admin_path: &str) -> Cookie<'static> {
4046 Cookie::build((REMEMBER_COOKIE, ""))
4047 .path(admin_path.to_string())
4048 .build()
4049}
4050
4051#[derive(Deserialize)]
4052struct SetupForm {
4053 username: String,
4054 first_name: String,
4055 last_name: String,
4056 email: String,
4057 password: String,
4058 timezone: String,
4059}
4060
4061async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
4064 match state.auth.has_any_operator().await {
4065 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
4066 Ok(false) => render(setup_view(
4067 &state.admin_path,
4068 state.brand().await,
4069 state.skin.clone(),
4070 state.timezone,
4071 None,
4072 pre_auth_translator(&state, &headers),
4073 state.asset_urls.clone(),
4074 state.auth.password_policy().min_length,
4075 )),
4076 Err(_) => render_error(),
4077 }
4078}
4079
4080async fn setup_submit(
4081 State(state): State<AdminState>,
4082 jar: CookieJar,
4083 headers: HeaderMap,
4084 client: Option<Extension<RequestContext>>,
4085 Form(form): Form<SetupForm>,
4086) -> Response {
4087 match state.auth.has_any_operator().await {
4089 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
4090 Ok(false) => {}
4091 Err(_) => return render_error(),
4092 }
4093
4094 let username = form.username.trim();
4095 let email = form.email.trim();
4096 let first_name = form.first_name.trim();
4097 let last_name = form.last_name.trim();
4098 let tz = form.timezone.trim();
4099 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
4100 {
4101 return render(setup_view(
4102 &state.admin_path,
4103 state.brand().await,
4104 state.skin.clone(),
4105 state.timezone,
4106 Some(t!(
4107 "Username, first name, email, and password are all required."
4108 )),
4109 pre_auth_translator(&state, &headers),
4110 state.asset_urls.clone(),
4111 state.auth.password_policy().min_length,
4112 ));
4113 }
4114 if tz.parse::<Tz>().is_err() {
4116 return render(setup_view(
4117 &state.admin_path,
4118 state.brand().await,
4119 state.skin.clone(),
4120 state.timezone,
4121 Some(t!("That is not a recognised timezone.")),
4122 pre_auth_translator(&state, &headers),
4123 state.asset_urls.clone(),
4124 state.auth.password_policy().min_length,
4125 ));
4126 }
4127
4128 let min_length = state.auth.password_policy().min_length;
4129 if form.password.chars().count() < min_length {
4130 return render(setup_view(
4131 &state.admin_path,
4132 state.brand().await,
4133 state.skin.clone(),
4134 state.timezone,
4135 Some(t!(
4136 "A password needs at least {n} characters.",
4137 n = min_length as i64
4138 )),
4139 pre_auth_translator(&state, &headers),
4140 state.asset_urls.clone(),
4141 state.auth.password_policy().min_length,
4142 ));
4143 }
4144 let new = NewOperator {
4145 username,
4146 email,
4147 first_name,
4148 last_name: (!last_name.is_empty()).then_some(last_name),
4149 password: &form.password,
4150 timezone: Some(tz),
4151 };
4152 let id = match state.auth.create_superuser(new).await {
4153 Ok(id) => id,
4154 Err(_) => {
4155 return render(setup_view(
4156 &state.admin_path,
4157 state.brand().await,
4158 state.skin.clone(),
4159 state.timezone,
4160 Some(t!(
4161 "Could not create the account. The username or email may already be taken."
4162 )),
4163 pre_auth_translator(&state, &headers),
4164 state.asset_urls.clone(),
4165 state.auth.password_policy().min_length,
4166 ));
4167 }
4168 };
4169 let actor = laterite_core::Actor::system("first-run setup");
4172 let target = id.to_string();
4173 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
4174 "backend_user",
4175 &target,
4176 Some(username),
4177 );
4178 if let Err(e) = state.auth.record_audit(entry).await {
4179 tracing::error!(error = %e, "failed to write audit log entry");
4180 }
4181 let completed = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4182 if let Err(e) = state.parameters.set(SETUP_COMPLETED_AT, &completed).await {
4183 tracing::warn!(error = %e, "failed to record the setup completion");
4184 }
4185
4186 match state
4188 .auth
4189 .authenticate(
4190 username,
4191 &form.password,
4192 &client.map(|c| c.0).unwrap_or_default(),
4193 )
4194 .await
4195 {
4196 Ok(session) => {
4197 let cookie = session_cookie(session.token, &state.cookie_path, state.secure_cookie);
4198 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
4199 }
4200 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
4201 }
4202}
4203
4204#[allow(clippy::too_many_arguments)]
4207fn setup_view(
4208 admin_path: &str,
4209 brand: Arc<settings::brand::Brand>,
4210 skin: Arc<skin::Active>,
4211 default_tz: Tz,
4212 error: Option<Text>,
4213 i18n: Translator,
4214 asset_urls: Arc<AssetUrls>,
4215 min_password: usize,
4216) -> SetupTemplate {
4217 let default_name = default_tz.name();
4218 let zones = TZ_VARIANTS
4219 .iter()
4220 .filter(|tz| zone_offered(tz.name()))
4221 .map(|tz| TzOption {
4222 name: tz.name().to_string(),
4223 selected: tz.name() == default_name,
4224 })
4225 .collect();
4226 SetupTemplate {
4227 base: admin_path.to_string(),
4228 asset_urls,
4229 brand: brand.name.clone(),
4230 look: brand,
4231 skin,
4232 zones,
4233 error: error.map(|e| i18n.t(&e)),
4234 min_password: min_password as i64,
4235 i18n,
4236 }
4237}
4238
4239async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
4240 if let Some(cookie) = jar.get(SESSION_COOKIE) {
4241 let _ = state.auth.logout(cookie.value()).await;
4242 }
4243 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
4246 let _ = state.auth.revoke_remember(cookie.value()).await;
4247 }
4248 let removal = Cookie::build((SESSION_COOKIE, ""))
4249 .path(state.cookie_path.to_string())
4250 .build();
4251 (
4252 jar.remove(removal)
4253 .remove(remember_removal(&state.cookie_path)),
4254 Redirect::to(&format!("{}/login", state.admin_path)),
4255 )
4256 .into_response()
4257}
4258
4259async fn dashboard(
4260 State(state): State<AdminState>,
4261 Extension(shell): Extension<Shell>,
4262 Extension(user): Extension<AuthenticatedUser>,
4263) -> Response {
4264 let layout = laterite_auth::store::user_preference(&state.db, user.user.id, widget::PREFERENCE)
4265 .await
4266 .ok()
4267 .flatten()
4268 .and_then(|json| widget::Layout::parse(&json));
4269 let deployment = state.order.get(widget::HIERARCHY).map(Vec::as_slice);
4270 let allows = |p: &str| user.permissions.allows(p);
4271 let available = widget::available(&state.widgets, deployment, &allows);
4272 let shown = widget::resolve(layout.as_ref(), &available);
4273 let failed = shell.tt(&t!("This widget could not be shown."));
4274 let mut widgets = Vec::with_capacity(shown.len());
4275 for (reg, size) in &shown {
4276 let cx = widget::WidgetCx {
4277 state: &state,
4278 shell: &shell,
4279 user: &user,
4280 size: *size,
4281 };
4282 let body = reg.widget.render(&cx).await;
4283 widgets.push(widget::frame(
4284 reg,
4285 *size,
4286 &shell.base,
4287 &shell.tt(®.widget.label()),
4288 body,
4289 &failed,
4290 ));
4291 }
4292 let available = available
4293 .iter()
4294 .map(|reg| {
4295 let placed = shown.iter().find(|(r, _)| r.code() == reg.code());
4296 AvailableWidgetView {
4297 code: reg.code().to_string(),
4298 label: shell.tt(®.widget.label()),
4299 description: reg
4300 .widget
4301 .description()
4302 .map(|d| shell.tt(&d))
4303 .unwrap_or_default(),
4304 shown: placed.is_some(),
4305 size: placed
4306 .map(|(_, s)| *s)
4307 .unwrap_or_else(|| reg.widget.size())
4308 .key()
4309 .to_string(),
4310 }
4311 })
4312 .collect();
4313 render(DashboardTemplate {
4314 shell,
4315 widgets,
4316 available,
4317 })
4318}
4319
4320async fn dashboard_widget(
4322 State(state): State<AdminState>,
4323 Extension(shell): Extension<Shell>,
4324 Extension(user): Extension<AuthenticatedUser>,
4325 Path(code): Path<String>,
4326 Query(query): Query<HashMap<String, String>>,
4327) -> Response {
4328 let Some(reg) = state.widgets.iter().find(|w| w.code() == code) else {
4329 return AdminError::NotFound.into_response();
4330 };
4331 if let Some(p) = reg.widget.permission() {
4332 if !user.permissions.allows(p) {
4333 return AdminError::Forbidden.into_response();
4334 }
4335 }
4336 let size = query
4337 .get("size")
4338 .and_then(|s| widget::Size::parse(s))
4339 .unwrap_or_else(|| reg.widget.size());
4340 let cx = widget::WidgetCx {
4341 state: &state,
4342 shell: &shell,
4343 user: &user,
4344 size,
4345 };
4346 match reg.widget.render(&cx).await {
4347 Ok(markup) => Html(markup.into_string()).into_response(),
4348 Err(e) => {
4349 tracing::warn!(widget = reg.code(), error = %e, "widget failed to render");
4350 Html(format!(
4351 r#"<p class="lat-widget__failed">{}</p>"#,
4352 form::escape_attr(&shell.tt(&t!("This widget could not be shown.")))
4353 ))
4354 .into_response()
4355 }
4356 }
4357}
4358
4359async fn dashboard_layout_update(
4362 State(state): State<AdminState>,
4363 Extension(user): Extension<AuthenticatedUser>,
4364 Extension(session): Extension<session::SessionHandle>,
4365 body: String,
4366) -> Response {
4367 let mut layout = widget::Layout::default();
4368 let mut sizes: HashMap<String, widget::Size> = HashMap::new();
4369 let mut order: Vec<String> = Vec::new();
4370 for (key, value) in form_urlencoded::parse(body.as_bytes()) {
4371 if key == "widget" {
4372 if state.widgets.iter().any(|w| w.code() == value) {
4373 order.push(value.to_string());
4374 }
4375 } else if let Some(code) = key.strip_prefix("size:") {
4376 if let Some(size) = widget::Size::parse(&value) {
4377 sizes.insert(code.to_string(), size);
4378 }
4379 }
4380 }
4381 for code in order {
4382 let size = sizes.get(&code).copied().unwrap_or_default();
4383 layout.widgets.push(widget::Placed { code, size });
4384 }
4385 let json = serde_json::to_string(&layout).unwrap_or_default();
4386 if let Err(e) = laterite_auth::store::set_user_preference(
4387 &state.db,
4388 user.user.id,
4389 widget::PREFERENCE,
4390 &json,
4391 )
4392 .await
4393 {
4394 tracing::error!(error = %e, "failed to save the dashboard layout");
4395 session.push_flash(FlashLevel::Error, t!("The dashboard could not be saved."));
4396 } else {
4397 session.push_flash(FlashLevel::Success, t!("Dashboard saved."));
4398 }
4399 Redirect::to(&state.admin_path).into_response()
4400}
4401
4402async fn dashboard_layout_reset(
4404 State(state): State<AdminState>,
4405 Extension(user): Extension<AuthenticatedUser>,
4406 Extension(session): Extension<session::SessionHandle>,
4407) -> Response {
4408 if let Err(e) =
4409 laterite_auth::store::clear_user_preference(&state.db, user.user.id, widget::PREFERENCE)
4410 .await
4411 {
4412 tracing::error!(error = %e, "failed to reset the dashboard layout");
4413 }
4414 session.push_flash(FlashLevel::Success, t!("Dashboard reset."));
4415 Redirect::to(&state.admin_path).into_response()
4416}
4417
4418struct AvailableWidgetView {
4420 code: String,
4421 label: String,
4422 description: String,
4423 shown: bool,
4424 size: String,
4425}
4426
4427fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
4430 match &item.permission {
4431 None => true,
4432 Some(p) => perms.allows(p),
4433 }
4434}
4435
4436fn visible_settings(
4440 items: &[settings::SettingsItem],
4441 perms: &PermissionSet,
4442) -> Vec<settings::SettingsItem> {
4443 items
4444 .iter()
4445 .filter(|item| operator_can_see(item, perms))
4446 .cloned()
4447 .collect()
4448}
4449
4450async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
4451 settings::index(shell)
4452}
4453
4454async fn settings_edit(
4455 state: AdminState,
4456 shell: Shell,
4457 user: AuthenticatedUser,
4458 code: String,
4459) -> Response {
4460 let items = visible_settings(&state.settings, &user.permissions);
4463 match items
4464 .iter()
4465 .find(|item| item.code == code && item.link.is_none())
4466 {
4467 Some(item) => settings::edit_form(&state, item, shell, &user).await,
4468 None => not_found(),
4469 }
4470}
4471
4472async fn settings_update(
4473 state: AdminState,
4474 shell: Shell,
4475 user: AuthenticatedUser,
4476 session: session::SessionHandle,
4477 code: String,
4478 data: HashMap<String, String>,
4479) -> Response {
4480 let items = visible_settings(&state.settings, &user.permissions);
4481 match items
4482 .iter()
4483 .find(|item| item.code == code && item.link.is_none())
4484 {
4485 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
4486 None => not_found(),
4487 }
4488}
4489
4490async fn preferences_form(
4492 State(state): State<AdminState>,
4493 Extension(shell): Extension<Shell>,
4494 Extension(user): Extension<AuthenticatedUser>,
4495 jar: CookieJar,
4496) -> Response {
4497 let account = account_security(&state, &user, &shell, &jar).await;
4498 let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
4499 .into_iter()
4500 .filter(|i| i.per_operator)
4501 .map(|i| PreferenceLink {
4502 label: shell.tt(&i.label),
4503 description: shell.tt(&i.description),
4504 href: i.path(&state.admin_path),
4505 })
4506 .collect();
4507 let own = OwnMenu::load(&state, user.user.id).await;
4508 let chosen = |key: &str| -> String {
4509 own.moves
4510 .get(key)
4511 .map(|m| {
4512 if m.hierarchy == nav::TOP_HIERARCHY {
4513 "nav".to_string()
4514 } else {
4515 "settings".to_string()
4516 }
4517 })
4518 .unwrap_or_default()
4519 };
4520 let mut menu_entries: Vec<MenuEntryView> = state
4521 .nav_items
4522 .iter()
4523 .filter(|i| i.path.is_some() && !matches!(i.key.as_str(), "dashboard" | "settings"))
4524 .filter(|i| {
4525 i.permission
4526 .as_deref()
4527 .is_none_or(|p| user.permissions.allows(p))
4528 })
4529 .map(|i| MenuEntryView {
4530 key: i.key.clone(),
4531 label: shell.tt(&i.label),
4532 home: "nav",
4533 chosen: chosen(&i.key),
4534 })
4535 .collect();
4536 menu_entries.extend(
4537 visible_settings(&state.settings, &user.permissions)
4538 .into_iter()
4539 .filter(|i| !i.per_operator)
4540 .map(|i| MenuEntryView {
4541 chosen: chosen(&i.code),
4542 key: i.code,
4543 label: shell.tt(&i.label),
4544 home: "settings",
4545 }),
4546 );
4547 render(preferences_view(
4548 &shell,
4549 &user,
4550 state.timezone,
4551 &state.default_locale,
4552 &offered_locales(&state.catalogs),
4553 account,
4554 state.auth.password_policy().min_length,
4555 items,
4556 menu_entries,
4557 ))
4558}
4559
4560struct PreferenceLink {
4562 label: String,
4563 description: String,
4564 href: String,
4565}
4566
4567#[derive(Deserialize)]
4568struct RevokeSessionForm {
4569 id: String,
4570}
4571
4572async fn session_revoke(
4574 State(state): State<AdminState>,
4575 Extension(user): Extension<AuthenticatedUser>,
4576 Extension(session): Extension<session::SessionHandle>,
4577 Form(form): Form<RevokeSessionForm>,
4578) -> Response {
4579 match state.auth.revoke_session(user.user.id, &form.id).await {
4580 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
4581 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
4582 Err(e) => {
4583 tracing::error!(error = %e, "revoking a session failed");
4584 session.push_flash(
4585 FlashLevel::Error,
4586 t!("That session could not be signed out."),
4587 );
4588 }
4589 }
4590 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4591}
4592
4593#[derive(Deserialize)]
4595struct MenuOrderForm {
4596 #[serde(default)]
4597 order: String,
4598 #[serde(default)]
4600 hierarchy: String,
4601}
4602
4603async fn menu_moves_update(
4607 State(state): State<AdminState>,
4608 Extension(user): Extension<AuthenticatedUser>,
4609 Extension(session): Extension<session::SessionHandle>,
4610 Form(form): Form<HashMap<String, String>>,
4611) -> Response {
4612 let mut moves = serde_json::Map::new();
4613 for (field, place) in &form {
4614 let Some(key) = field.strip_prefix("place:") else {
4615 continue;
4616 };
4617 if key.is_empty() || key.len() > 200 || !matches!(place.as_str(), "nav" | "settings") {
4618 continue;
4619 }
4620 moves.insert(key.to_string(), serde_json::Value::String(place.clone()));
4621 }
4622 let result = if moves.is_empty() {
4623 laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::MOVES_PREFERENCE)
4624 .await
4625 } else {
4626 laterite_auth::store::set_user_preference(
4627 &state.db,
4628 user.user.id,
4629 nav::MOVES_PREFERENCE,
4630 &serde_json::Value::Object(moves).to_string(),
4631 )
4632 .await
4633 };
4634 if let Err(e) = result {
4635 tracing::error!(error = %e, "storing the menu moves failed");
4636 return render_error();
4637 }
4638 session.push_flash(session::FlashLevel::Success, t!("Menu saved."));
4639 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4640}
4641
4642async fn markdown_preview(
4645 Extension(_user): Extension<AuthenticatedUser>,
4646 Form(form): Form<HashMap<String, String>>,
4647) -> Response {
4648 let text = form.get("text").map(String::as_str).unwrap_or_default();
4649 if text.len() > 1 << 20 {
4650 return (axum::http::StatusCode::PAYLOAD_TOO_LARGE, "text over 1 MiB").into_response();
4651 }
4652 Html(laterite_core::markdown::render(text)).into_response()
4653}
4654
4655async fn editor_menu(
4658 State(state): State<AdminState>,
4659 Extension(shell): Extension<Shell>,
4660 Extension(_user): Extension<AuthenticatedUser>,
4661 Query(query): Query<HashMap<String, String>>,
4662) -> Response {
4663 let Some(surface) = query.get("surface").and_then(|k| editor::Surface::parse(k)) else {
4664 return (axum::http::StatusCode::BAD_REQUEST, "unknown surface").into_response();
4665 };
4666 let registry = &state.plugin_defined;
4667 let mut menu = editor::Menu::default();
4668 for cmd in editor::commands(
4669 registry.items::<editor::EditorCommand>().into_iter(),
4670 surface,
4671 ) {
4672 let (html, text) = match &cmd.insert {
4673 editor::Insert::Html(h) => (Some(h.clone()), None),
4674 editor::Insert::Text(t) => (None, Some(t.clone())),
4675 };
4676 menu.commands.push(editor::MenuCommand {
4677 key: cmd.key.clone(),
4678 label: shell.tt(&cmd.label),
4679 html,
4680 text,
4681 });
4682 }
4683 for source in registry.items::<editor::SnippetSourceReg>() {
4684 menu.snippets.extend(source.0.list(&state.db).await);
4685 }
4686 menu.snippets.sort_by(|a, b| a.name.cmp(&b.name));
4687 axum::Json(menu).into_response()
4688}
4689
4690async fn menu_order_update(
4695 State(state): State<AdminState>,
4696 Extension(user): Extension<AuthenticatedUser>,
4697 Extension(session): Extension<session::SessionHandle>,
4698 headers: axum::http::HeaderMap,
4699 Form(form): Form<MenuOrderForm>,
4700) -> Response {
4701 let order: Vec<&str> = form
4702 .order
4703 .split(',')
4704 .map(str::trim)
4705 .filter(|k| !k.is_empty() && k.len() <= 200)
4706 .take(100)
4707 .collect();
4708 let hierarchy = if form.hierarchy.is_empty() {
4709 nav::TOP_HIERARCHY
4710 } else {
4711 form.hierarchy.as_str()
4712 };
4713 if !nav::OPERATOR_HIERARCHIES.contains(&hierarchy) {
4714 return AdminError::NotFound.into_response();
4715 }
4716 let key = nav::order_preference(hierarchy);
4717 let result = if order.is_empty() {
4718 let mut cleared = Ok(());
4720 for key in nav::OPERATOR_HIERARCHIES
4721 .iter()
4722 .map(|h| nav::order_preference(h))
4723 .chain(std::iter::once(nav::MOVES_PREFERENCE.to_string()))
4724 {
4725 if let Err(e) =
4726 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
4727 {
4728 cleared = Err(e);
4729 }
4730 }
4731 cleared
4732 } else {
4733 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &order.join(","))
4734 .await
4735 };
4736 if let Err(e) = result {
4737 tracing::error!(error = %e, "storing the menu order failed");
4738 return render_error();
4739 }
4740 if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
4741 return axum::http::StatusCode::NO_CONTENT.into_response();
4742 }
4743 session.push_flash(
4744 FlashLevel::Success,
4745 if order.is_empty() {
4746 t!("The menu is back in its default order.")
4747 } else {
4748 t!("Saved.")
4749 },
4750 );
4751 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
4752}
4753
4754async fn session_revoke_others(
4755 State(state): State<AdminState>,
4756 Extension(user): Extension<AuthenticatedUser>,
4757 Extension(session): Extension<session::SessionHandle>,
4758 jar: CookieJar,
4759) -> Response {
4760 let token = jar
4761 .get(SESSION_COOKIE)
4762 .map(|c| c.value().to_string())
4763 .unwrap_or_default();
4764 match state.auth.sign_out_everywhere(user.user.id, &token).await {
4765 Ok(_) => session.push_flash(
4766 FlashLevel::Success,
4767 t!("Every other session was signed out."),
4768 ),
4769 Err(e) => {
4770 tracing::error!(error = %e, "signing out other sessions failed");
4771 session.push_flash(
4772 FlashLevel::Error,
4773 t!("Those sessions could not be signed out."),
4774 );
4775 }
4776 }
4777 (
4780 jar.remove(remember_removal(&state.cookie_path)),
4781 Redirect::to(&format!("{}/preferences", state.admin_path)),
4782 )
4783 .into_response()
4784}
4785
4786#[derive(Deserialize)]
4787struct PasswordForm {
4788 current_password: String,
4789 new_password: String,
4790 confirm_password: String,
4791}
4792
4793async fn password_update(
4798 State(state): State<AdminState>,
4799 Extension(user): Extension<AuthenticatedUser>,
4800 Extension(session): Extension<session::SessionHandle>,
4801 Extension(ctx): Extension<RequestContext>,
4802 jar: CookieJar,
4803 Form(form): Form<PasswordForm>,
4804) -> Response {
4805 let back = format!("{}/preferences#password", state.admin_path);
4806 let refuse = |message: Text| {
4807 session.push_flash(FlashLevel::Error, message);
4808 Redirect::to(&back).into_response()
4809 };
4810 if form.new_password != form.confirm_password {
4811 return refuse(t!("The new passwords do not match."));
4812 }
4813 let min_length = state.auth.password_policy().min_length;
4814 if form.new_password.chars().count() < min_length {
4815 return refuse(t!(
4816 "A password needs at least {n} characters.",
4817 n = min_length as i64
4818 ));
4819 }
4820 let remembered_here = match jar.get(REMEMBER_COOKIE) {
4823 Some(cookie) => {
4824 state
4825 .auth
4826 .remember_holder(cookie.value())
4827 .await
4828 .ok()
4829 .flatten()
4830 == Some(user.user.id)
4831 }
4832 None => false,
4833 };
4834 let token = jar
4835 .get(SESSION_COOKIE)
4836 .map(|c| c.value().to_string())
4837 .unwrap_or_default();
4838 let changed = state
4839 .auth
4840 .change_own_password(
4841 user.user.id,
4842 &form.current_password,
4843 &form.new_password,
4844 &token,
4845 &ctx,
4846 )
4847 .await;
4848 match changed {
4849 Ok(()) => {}
4850 Err(laterite_auth::AuthError::InvalidCredentials) => {
4851 return refuse(t!("Your current password is not correct."));
4852 }
4853 Err(laterite_auth::AuthError::TooManyAttempts) => {
4854 return refuse(t!("Too many attempts. Try again later."));
4855 }
4856 Err(e) => {
4857 tracing::error!(error = %e, "changing a password failed");
4858 return refuse(t!("The password could not be changed."));
4859 }
4860 }
4861 session.push_flash_sticky(
4862 FlashLevel::Success,
4863 t!("Password changed. Every other device was signed out."),
4864 );
4865 let jar = if remembered_here {
4866 match state.auth.issue_remember(user.user.id).await {
4867 Ok(credential) => jar.add(remember_cookie(
4868 credential,
4869 &state.cookie_path,
4870 state.secure_cookie,
4871 )),
4872 Err(e) => {
4873 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
4874 jar.remove(remember_removal(&state.cookie_path))
4875 }
4876 }
4877 } else if jar.get(REMEMBER_COOKIE).is_some() {
4878 jar.remove(remember_removal(&state.cookie_path))
4881 } else {
4882 jar
4883 };
4884 (
4885 jar,
4886 Redirect::to(&format!("{}/preferences", state.admin_path)),
4887 )
4888 .into_response()
4889}
4890
4891#[derive(Deserialize)]
4892struct PreferencesForm {
4893 timezone: String,
4894 #[serde(default)]
4896 locale: String,
4897}
4898
4899async fn preferences_update(
4900 State(state): State<AdminState>,
4901 Extension(user): Extension<AuthenticatedUser>,
4902 Extension(session): Extension<session::SessionHandle>,
4903 Form(form): Form<PreferencesForm>,
4904) -> Response {
4905 let back = format!("{}/preferences", state.admin_path);
4906 let offered = offered_locales(&state.catalogs);
4907 let tz = form.timezone.trim();
4909 let tz_stored = if tz.is_empty() {
4910 None
4911 } else if tz.parse::<Tz>().is_ok() {
4912 Some(tz)
4913 } else {
4914 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
4915 return Redirect::to(&back).into_response();
4916 };
4917 let loc = form.locale.trim();
4918 let loc_stored = if loc.is_empty() {
4919 None
4920 } else if offered.iter().any(|l| l == loc) {
4921 Some(loc)
4922 } else {
4923 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
4924 return Redirect::to(&back).into_response();
4925 };
4926 if state
4927 .auth
4928 .set_user_timezone(user.user.id, tz_stored)
4929 .await
4930 .is_err()
4931 {
4932 return render_error();
4933 }
4934 match state.auth.set_user_locale(user.user.id, loc_stored).await {
4935 Ok(()) => {
4936 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
4937 Redirect::to(&back).into_response()
4938 }
4939 Err(_) => render_error(),
4940 }
4941}
4942
4943#[allow(clippy::too_many_arguments)]
4947fn preferences_view(
4948 shell: &Shell,
4949 user: &AuthenticatedUser,
4950 default_tz: Tz,
4951 default_locale: &str,
4952 offered: &[String],
4953 account: AccountSecurity,
4954 min_password: usize,
4955 items: Vec<PreferenceLink>,
4956 menu_entries: Vec<MenuEntryView>,
4957) -> PreferencesTemplate {
4958 let current = user.user.timezone.as_deref();
4959 let zones = TZ_VARIANTS
4960 .iter()
4961 .filter(|tz| zone_offered(tz.name()))
4962 .map(|tz| TzOption {
4963 name: tz.name().to_string(),
4964 selected: current == Some(tz.name()),
4965 })
4966 .collect();
4967 let current_locale = user.user.locale.as_deref();
4968 let locales = offered
4969 .iter()
4970 .map(|code| LocaleOption {
4971 name: locale_name(code),
4972 selected: current_locale == Some(code.as_str()),
4973 code: code.clone(),
4974 })
4975 .collect();
4976 PreferencesTemplate {
4977 shell: shell.clone(),
4978 zones,
4979 effective_tz: shell.tz.name().to_string(),
4980 default_tz: default_tz.name().to_string(),
4981 inherits: current.is_none(),
4982 locales,
4983 default_locale: locale_name(default_locale),
4984 inherits_locale: current_locale.is_none(),
4985 sessions: account.sessions,
4986 password_changed: account.password_changed,
4987 must_change: account.must_change,
4988 min_password: min_password as i64,
4989 items,
4990 menu_entries,
4991 }
4992}
4993
4994struct AccountSecurity {
4997 sessions: Vec<SessionRow>,
4998 password_changed: Option<String>,
4999 must_change: bool,
5001}
5002
5003async fn account_security(
5004 state: &AdminState,
5005 user: &AuthenticatedUser,
5006 shell: &Shell,
5007 jar: &CookieJar,
5008) -> AccountSecurity {
5009 let locale = list::date_locale(shell.locale());
5010 let password_changed = match state.auth.password_changed_at(user.user.id).await {
5011 Ok(at) => {
5012 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
5013 }
5014 Err(e) => {
5015 tracing::error!(error = %e, "reading the password change time failed");
5016 None
5017 }
5018 };
5019 AccountSecurity {
5020 sessions: session_rows(state, user, shell, jar).await,
5021 password_changed,
5022 must_change: state
5023 .auth
5024 .must_change_password(user.user.id)
5025 .await
5026 .unwrap_or(false),
5027 }
5028}
5029
5030async fn session_rows(
5033 state: &AdminState,
5034 user: &AuthenticatedUser,
5035 shell: &Shell,
5036 jar: &CookieJar,
5037) -> Vec<SessionRow> {
5038 let token = jar
5039 .get(SESSION_COOKIE)
5040 .map(|c| c.value().to_string())
5041 .unwrap_or_default();
5042 let locale = list::date_locale(shell.locale());
5043 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
5044 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
5045 };
5046 match state.auth.list_sessions(user.user.id, &token).await {
5047 Ok(sessions) => sessions
5048 .into_iter()
5049 .map(|s| SessionRow {
5050 id: s.id,
5051 started: stamp(s.created_at),
5052 last_seen: stamp(s.last_seen_at),
5053 expires: stamp(s.expires_at),
5054 device: clientip::describe(s.user_agent.as_deref())
5055 .or(s.user_agent)
5056 .unwrap_or_else(|| "-".to_string()),
5057 ip_address: s.ip_address.unwrap_or_default(),
5058 current: s.current,
5059 })
5060 .collect(),
5061 Err(e) => {
5062 tracing::error!(error = %e, "listing sessions failed");
5063 Vec::new()
5064 }
5065 }
5066}
5067
5068fn builtin_resources() -> Vec<Resource> {
5070 vec![
5071 Resource {
5072 base_path: "/users".to_string(),
5073 nav_label: "Administrators".into(),
5074 list: backend_users_list_config(),
5075 form: None,
5076 permission: Some("backend.manage_users".to_string()),
5077 import: None,
5078 palette: None,
5079 relations: Vec::new(),
5080 menu: nav::Placement::default(),
5081 },
5082 Resource {
5083 base_path: "/roles".to_string(),
5084 nav_label: "Roles".into(),
5085 list: roles_list_config(),
5086 form: None,
5089 permission: Some("backend.manage_roles".to_string()),
5090 import: None,
5091 palette: None,
5092 relations: Vec::new(),
5093 menu: nav::Placement::default(),
5094 },
5095 resource!("descriptors/audit-log.yaml"),
5096 resource!("descriptors/access-log.yaml"),
5097 resource!("descriptors/request-log.yaml"),
5098 ]
5099}
5100
5101fn builtin_settings() -> Vec<settings::SettingsItem> {
5105 vec![
5106 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
5107 .description("Manage backend administrator accounts.")
5108 .category("Users")
5109 .slot(laterite_core::arrange::slot::PEOPLE)
5110 .order(10)
5111 .icon("users")
5112 .permission("backend.manage_users")
5113 .link("/users"),
5114 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
5115 .description("Manage roles and their permissions.")
5116 .category("Users")
5117 .slot(laterite_core::arrange::slot::PEOPLE)
5118 .order(20)
5119 .icon("shield")
5120 .permission("backend.manage_roles")
5121 .link("/roles"),
5122 settings::brand::settings_item(),
5123 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
5124 .description("Enable or disable installed plugins.")
5125 .category("System")
5126 .slot(laterite_core::arrange::slot::SYSTEM)
5127 .order(10)
5128 .icon("plug")
5129 .permission(plugins::MANAGE_PERMISSION)
5130 .link("/plugins"),
5131 settings::SettingsItem::new("backend.jobs", "Jobs", Vec::new())
5132 .description("Watch the queues and retry or drop failed jobs.")
5133 .category("System")
5134 .slot(laterite_core::arrange::slot::SYSTEM)
5135 .order(12)
5136 .icon("layers")
5137 .permission(jobs::PERMISSION)
5138 .link("/jobs"),
5139 settings::SettingsItem::new("backend.scheduled_tasks", "Scheduled Tasks", Vec::new())
5140 .description("See when each scheduled entry last ran and run one now.")
5141 .category("System")
5142 .slot(laterite_core::arrange::slot::SYSTEM)
5143 .order(13)
5144 .icon("clock")
5145 .permission(scheduled::PERMISSION)
5146 .link("/scheduled-tasks"),
5147 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
5148 .description("Review the record of administrative changes.")
5149 .category("System")
5150 .slot(laterite_core::arrange::slot::SYSTEM)
5151 .order(20)
5152 .icon("history")
5153 .permission("backend.view_audit_log")
5154 .link("/audit-log"),
5155 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
5156 .description("Review sign-ins, failures and lockouts.")
5157 .category("System")
5158 .slot(laterite_core::arrange::slot::SYSTEM)
5159 .order(30)
5160 .icon("log-in")
5161 .permission("backend.view_access_log")
5162 .link("/access-log"),
5163 settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
5164 .description("Review the URLs the panel could not serve.")
5165 .category("System")
5166 .slot(laterite_core::arrange::slot::SYSTEM)
5167 .order(31)
5168 .icon("unlink")
5169 .permission("backend.view_request_log")
5170 .link("/request-log"),
5171 ]
5172}
5173
5174fn backend_users_list_config() -> list::ListConfig {
5175 list::ListConfig {
5176 entity: "backend_users".to_string(),
5177 title: "Administrators".into(),
5178 columns: vec![
5179 list::ListColumn::new("username", "Username"),
5180 list::ListColumn::new("email", "Email"),
5181 list::ListColumn::new("first_name", "First name"),
5182 list::ListColumn::new("last_name", "Last name"),
5183 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
5184 list::ListColumn::new("is_active", "Active").yes_no(),
5185 list::ListColumn::new("created_at", "Created").datetime(),
5186 ],
5187 order_by: "created_at".to_string(),
5188 creatable: true,
5191 edit_base: Some("/users".to_string()),
5192 filters: vec![
5193 list::ListFilter::boolean("is_active", "Active"),
5194 list::ListFilter::boolean("is_superuser", "Superuser"),
5195 ],
5196 ..Default::default()
5200 }
5201}
5202
5203fn roles_list_config() -> list::ListConfig {
5204 list::ListConfig {
5205 entity: "backend_roles".to_string(),
5206 title: "Roles".into(),
5207 columns: vec![
5208 list::ListColumn::new("code", "Code"),
5209 list::ListColumn::new("name", "Name"),
5210 list::ListColumn::new("created_at", "Created").datetime(),
5211 ],
5212 order_by: "created_at".to_string(),
5213 edit_base: Some("/roles".to_string()),
5214 creatable: true,
5215 deletable: true,
5216 ..Default::default()
5217 }
5218}
5219
5220#[derive(Template)]
5221#[template(path = "login.html")]
5222struct LoginTemplate {
5223 base: String,
5225 asset_urls: Arc<AssetUrls>,
5227 brand: String,
5228 look: Arc<settings::brand::Brand>,
5229 skin: Arc<skin::Active>,
5230 error: Option<String>,
5231 notice: Option<String>,
5233 i18n: Translator,
5236}
5237
5238fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
5241 match urls.get(key) {
5242 Some(path) => format!("{base}/assets/{path}"),
5243 None => {
5244 debug_assert!(false, "asset key `{key}` is not registered");
5245 format!("{base}/assets/{key}")
5246 }
5247 }
5248}
5249
5250impl LoginTemplate {
5251 fn asset(&self, key: &str) -> String {
5252 preauth_asset(&self.asset_urls, &self.base, key)
5253 }
5254 fn t(&self, source: &str) -> String {
5255 self.i18n.t(&Text::dynamic(source))
5256 }
5257 fn locale(&self) -> &str {
5258 self.i18n.locale()
5259 }
5260}
5261
5262#[derive(Template)]
5263#[template(path = "dashboard.html")]
5264struct DashboardTemplate {
5265 shell: Shell,
5266 widgets: Vec<String>,
5268 available: Vec<AvailableWidgetView>,
5269}
5270
5271#[derive(Template)]
5272#[template(path = "setup.html")]
5273struct SetupTemplate {
5274 base: String,
5276 asset_urls: Arc<AssetUrls>,
5278 brand: String,
5279 look: Arc<settings::brand::Brand>,
5280 skin: Arc<skin::Active>,
5281 zones: Vec<TzOption>,
5282 error: Option<String>,
5283 i18n: Translator,
5285 min_password: i64,
5287}
5288
5289impl SetupTemplate {
5290 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
5293 let mut text = Text::dynamic(source);
5294 for (name, value) in args {
5295 text = text.arg(*name, *value);
5296 }
5297 self.i18n.t(&text)
5298 }
5299
5300 fn asset(&self, key: &str) -> String {
5301 preauth_asset(&self.asset_urls, &self.base, key)
5302 }
5303 fn t(&self, source: &str) -> String {
5304 self.i18n.t(&Text::dynamic(source))
5305 }
5306 fn locale(&self) -> &str {
5307 self.i18n.locale()
5308 }
5309}
5310
5311#[derive(Template)]
5312#[template(path = "preferences.html")]
5313struct PreferencesTemplate {
5314 shell: Shell,
5315 zones: Vec<TzOption>,
5316 effective_tz: String,
5318 default_tz: String,
5320 inherits: bool,
5322 locales: Vec<LocaleOption>,
5323 default_locale: String,
5325 inherits_locale: bool,
5327 sessions: Vec<SessionRow>,
5329 password_changed: Option<String>,
5331 must_change: bool,
5333 min_password: i64,
5335 items: Vec<PreferenceLink>,
5337 menu_entries: Vec<MenuEntryView>,
5339}
5340
5341struct MenuEntryView {
5344 key: String,
5345 label: String,
5346 home: &'static str,
5348 chosen: String,
5350}
5351
5352struct SessionRow {
5354 id: String,
5355 started: String,
5356 last_seen: String,
5357 expires: String,
5358 device: String,
5361 ip_address: String,
5363 current: bool,
5364}
5365
5366struct TzOption {
5367 name: String,
5368 selected: bool,
5369}
5370
5371struct LocaleOption {
5372 code: String,
5374 name: String,
5376 selected: bool,
5377}
5378
5379#[derive(Clone)]
5381struct CrumbView {
5382 label: String,
5383 href: String,
5384}
5385
5386#[derive(Clone)]
5387struct NavView {
5388 key: String,
5390 label: String,
5391 path: String,
5392 active: bool,
5393 icon: String,
5396}
5397
5398pub(crate) fn render<T: Template>(template: T) -> Response {
5400 match template.render() {
5401 Ok(html) => Html(html).into_response(),
5402 Err(e) => AdminError::from(e).into_response(),
5404 }
5405}
5406
5407pub(crate) fn render_error() -> Response {
5408 error::masked_500()
5409}
5410
5411pub(crate) fn not_found() -> Response {
5412 AdminError::NotFound.into_response()
5413}
5414
5415pub(crate) fn forbidden() -> Response {
5416 AdminError::Forbidden.into_response()
5417}
5418
5419#[cfg(test)]
5420mod tests {
5421 use super::*;
5422
5423 fn write_form() -> form::FormConfig {
5424 form::FormConfig {
5425 enter: Default::default(),
5426 confirm_leave: true,
5427 focus: Default::default(),
5428 entity: "widgets".to_string(),
5429 title: "Widgets".into(),
5430 base_path: "/widgets".to_string(),
5431 id_field: "id".to_string(),
5432 fields: vec![],
5433 persist: None,
5434 timestamps: false,
5435 done_path: None,
5436 }
5437 }
5438
5439 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
5440 Resource {
5441 base_path: "/widgets".to_string(),
5442 nav_label: "Widgets".into(),
5443 list: backend_users_list_config(),
5444 form,
5445 permission: permission.map(str::to_string),
5446 import: None,
5447 palette: None,
5448 relations: Vec::new(),
5449 menu: nav::Placement::default(),
5450 }
5451 }
5452
5453 #[test]
5454 #[should_panic(expected = "must declare a permission")]
5455 fn write_resource_without_permission_aborts_boot() {
5456 let resource = resource_with(Some(write_form()), None);
5459 let _ = mount_resource(
5460 &resource,
5461 &field::builtin_registry(),
5462 &list::builtin_column_registry(),
5463 &std::collections::HashSet::new(),
5464 &persist::PersisterRegistry::new(),
5465 &[],
5466 );
5467 }
5468
5469 #[test]
5471 fn the_access_log_is_read_from_its_descriptor_file() {
5472 let access = builtin_resources()
5473 .into_iter()
5474 .find(|r| r.base_path == "/access-log")
5475 .expect("the access log is registered");
5476 assert_eq!(access.list.entity, "backend_access_log");
5477 assert_eq!(
5478 access.permission.as_deref(),
5479 Some("backend.view_access_log")
5480 );
5481 assert!(access.form.is_none(), "append-only");
5482 assert_eq!(
5483 access
5484 .list
5485 .columns
5486 .iter()
5487 .map(|c| c.field.as_str())
5488 .collect::<Vec<_>>(),
5489 [
5490 "created_at",
5491 "username_attempted",
5492 "event",
5493 "ip_address",
5494 "user_agent"
5495 ]
5496 );
5497 }
5498
5499 #[test]
5502 fn the_audit_log_is_read_from_its_descriptor_file() {
5503 let audit = builtin_resources()
5504 .into_iter()
5505 .find(|r| r.base_path == "/audit-log")
5506 .expect("the audit log is registered");
5507 assert_eq!(audit.list.entity, "backend_audit_log");
5508 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
5509 assert_eq!(
5510 audit
5511 .list
5512 .columns
5513 .iter()
5514 .map(|c| c.field.as_str())
5515 .collect::<Vec<_>>(),
5516 [
5517 "created_at",
5518 "actor_username",
5519 "action",
5520 "target_type",
5521 "target_id",
5522 "target_label"
5523 ],
5524 "in the order the file writes them"
5525 );
5526 assert_eq!(audit.list.columns[0].column_type, "datetime");
5527 assert_eq!(audit.list.per_page, 50);
5528 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
5529 assert!(audit.list.exportable, "evidence may leave the panel");
5530 assert!(audit.form.is_none(), "append-only: no form");
5531 assert!(!audit.list.deletable, "and nothing removes from it");
5532 }
5533
5534 #[test]
5535 #[should_panic(expected = "uses unregistered type `nope`")]
5536 fn unregistered_column_type_aborts_boot() {
5537 let mut resource = resource_with(None, None);
5538 let mut score = list::ListColumn::new("score", "Score");
5539 score.column_type = "nope".into();
5540 resource.list.columns.push(score);
5541 let _ = mount_resource(
5542 &resource,
5543 &field::builtin_registry(),
5544 &list::builtin_column_registry(),
5545 &std::collections::HashSet::new(),
5546 &persist::PersisterRegistry::new(),
5547 &[],
5548 );
5549 }
5550
5551 #[test]
5552 fn read_only_resource_may_omit_permission() {
5553 let resource = resource_with(None, None);
5555 let _ = mount_resource(
5556 &resource,
5557 &field::builtin_registry(),
5558 &list::builtin_column_registry(),
5559 &std::collections::HashSet::new(),
5560 &persist::PersisterRegistry::new(),
5561 &[],
5562 );
5563 }
5564
5565 #[test]
5566 fn shell_localizes_through_the_translator_and_falls_back() {
5567 let shell = Shell::test();
5569 assert_eq!(shell.t("Save"), "Save");
5570 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
5571 }
5572
5573 #[test]
5574 fn shell_tf_interpolates_integer_arguments() {
5575 let shell = Shell::test();
5576 assert_eq!(
5577 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
5578 "Page 2 of 5"
5579 );
5580 }
5581
5582 #[test]
5583 fn display_tz_prefers_a_valid_operator_preference() {
5584 assert_eq!(
5585 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
5586 Tz::Asia__Kolkata
5587 );
5588 }
5589
5590 #[test]
5591 fn display_tz_falls_back_when_unset_or_invalid() {
5592 let default = Tz::Europe__London;
5593 assert_eq!(resolve_display_tz(None, default), default);
5595 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
5597 assert_eq!(resolve_display_tz(Some(""), default), default);
5598 }
5599
5600 #[test]
5601 fn accept_language_orders_by_weight_and_drops_wildcard() {
5602 assert_eq!(
5603 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
5604 vec!["en-US", "en", "kn"]
5605 );
5606 assert_eq!(
5608 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
5609 vec!["kn", "en"]
5610 );
5611 }
5612
5613 #[test]
5614 fn locale_chain_prefers_operator_then_header_then_default() {
5615 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
5617 assert_eq!(
5620 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
5621 vec!["kn", "ta", "hi", "en"]
5622 );
5623 assert_eq!(
5625 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
5626 vec!["kn", "en", "ta"]
5627 );
5628 assert_eq!(
5630 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
5631 vec!["kn", "en"]
5632 );
5633 }
5634
5635 #[test]
5636 fn locale_chain_skips_unserveable_and_defaults_to_en() {
5637 let loaded = ["hi".to_string()];
5639 assert_eq!(
5642 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
5643 vec!["en"]
5644 );
5645 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
5647 assert_eq!(
5649 resolve_locale_chain(Some("hi"), None, "en", &loaded),
5650 vec!["hi", "en"]
5651 );
5652 assert_eq!(
5654 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
5655 vec![laterite_core::PSEUDO_LOCALE, "en"]
5656 );
5657 }
5658
5659 #[test]
5660 fn a_page_with_an_aside_renders_the_fold_beside_the_body() {
5661 use askama::Template;
5662 #[derive(askama::Template)]
5663 #[template(source = "<p>{{ text }}</p>", ext = "html")]
5664 struct Para {
5665 text: &'static str,
5666 }
5667 let para = |text| html::Markup::from_template(&Para { text }).unwrap();
5668 let page = PageTemplate {
5669 title: "Spec".to_string(),
5670 back: None,
5671 actions: None,
5672 aside: Some(("Chapters".to_string(), para("one"))),
5673 body: para("body"),
5674 shell: Shell::test(),
5675 };
5676 let html = page.render().unwrap();
5677 let panel = html.find("<aside class=\"lat-aside\"").expect("aside");
5678 let body = html.find("<p>body</p>").expect("body");
5679 assert!(panel < body, "the panel comes before the body: {html}");
5680 assert!(html.contains("data-lat-widget=\"aside\""));
5681 assert!(html.contains("<summary class=\"lat-aside__summary\">Chapters</summary>"));
5682 assert!(html.contains("aria-label=\"Chapters\""));
5683 let plain = PageTemplate {
5684 title: "Spec".to_string(),
5685 back: None,
5686 actions: None,
5687 aside: None,
5688 body: para("body"),
5689 shell: Shell::test(),
5690 };
5691 assert!(!plain.render().unwrap().contains("lat-aside"));
5692 }
5693
5694 #[test]
5695 fn page_head_assets_dedupe_and_keep_order() {
5696 let head = PageHead::new("Specification")
5697 .assets(CODE_EDITOR_ASSETS)
5698 .assets(&["fields/codeeditor.js", "fields/ref-picker.js"]);
5699 assert_eq!(
5700 head.assets,
5701 vec![
5702 "vendor/codeeditor.js",
5703 "fields/codeeditor.js",
5704 "fields/codeeditor.css",
5705 "fields/ref-picker.js"
5706 ]
5707 );
5708 }
5709
5710 #[test]
5711 fn page_assets_dedupe_preserve_order_and_classify() {
5712 let mut reg = AssetRegistry::new();
5713 reg.insert(
5714 "a.js",
5715 AdminAsset {
5716 mime: "text/javascript; charset=utf-8",
5717 bytes: std::borrow::Cow::Borrowed(b"//"),
5718 },
5719 );
5720 reg.insert(
5721 "b.css",
5722 AdminAsset {
5723 mime: "text/css; charset=utf-8",
5724 bytes: std::borrow::Cow::Borrowed(b"body{}"),
5725 },
5726 );
5727 let urls = asset_urls(®);
5730 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
5731 assert_eq!(assets.len(), 2);
5732 assert_eq!(
5733 assets[0].url,
5734 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
5735 );
5736 assert!(!assets[0].css);
5737 assert_eq!(
5738 assets[1].url,
5739 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
5740 );
5741 assert!(assets[1].css);
5742 assert_ne!(assets[0].url, assets[1].url);
5745 }
5746
5747 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
5751 laterite_core::testing::connect_test(&[]).await
5752 }
5753
5754 #[tokio::test]
5755 async fn builtin_migrations_create_the_admin_tables() {
5756 let (db, _guard) = empty_db().await;
5757 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
5758 .await
5759 .unwrap();
5760 for table in ["backend_users", "settings"] {
5764 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
5765 .fetch_optional(&db.pool)
5766 .await;
5767 assert!(
5768 probe.is_ok(),
5769 "{table} should exist after builtin_migrations"
5770 );
5771 }
5772 }
5773
5774 #[tokio::test]
5775 async fn brand_setting_overrides_config_and_blank_falls_back() {
5776 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
5777 let state = AdminState {
5778 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
5779 db: db.clone(),
5780 parameters: laterite_core::parameters::Parameters::new(db.clone()),
5781 upload_extensions: Arc::new(upload_extensions(&[])),
5782 nav: Arc::new(nav::NavTree::default()),
5783 nav_items: Arc::new(Vec::new()),
5784 settings: Arc::new(Vec::new()),
5785 permissions: Arc::new(builtin_permissions()),
5786 trusted_proxies: Arc::new(Vec::new()),
5787 icons: Arc::new(laterite_core::icons::IconSet::new()),
5788 sprite_url: Arc::from(""),
5789 admin_path: Arc::from("/admin"),
5790 cookie_path: Arc::from("/admin"),
5791 storage_dir: Arc::from("storage"),
5792 crypt: Arc::new(laterite_core::Crypt::ephemeral()),
5793 order: std::collections::BTreeMap::new(),
5794 secure_cookie: false,
5795 origin: Arc::from(""),
5796 timezone: Tz::UTC,
5797 default_locale: Arc::from("en"),
5798 catalogs: Arc::new(CatalogStore::default()),
5799 app_name: "Configured Name".to_string(),
5800 cache: laterite_core::Cache::local(),
5801 queue: inline_queue(&db, laterite_core::Cache::local()),
5802 schedule: empty_schedule(
5803 &db,
5804 laterite_core::Cache::local(),
5805 inline_queue(&db, laterite_core::Cache::local()),
5806 ),
5807 skin: Arc::new(skin::Active::default()),
5808 field_types: Arc::new(field::builtin_registry()),
5809 column_types: Arc::new(list::builtin_column_registry()),
5810 widgets: Arc::new(widgets::builtin()),
5811 plugin_defined: Arc::new(laterite_core::Registry::new()),
5812 assets: Arc::new(builtin_assets()),
5813 asset_urls: Arc::new(asset_urls(&builtin_assets())),
5814 pickers: Arc::new(picker::PickerRegistry::new()),
5815 palette: Arc::new(Vec::new()),
5816 overrides: Arc::new(field::NoOverrides),
5817 };
5818
5819 assert_eq!(state.brand().await.name, "Configured Name");
5821
5822 state
5825 .settings_store()
5826 .save(&settings::BrandSetting {
5827 app_name: "Acme Corp".to_string(),
5828 ..Default::default()
5829 })
5830 .await
5831 .unwrap();
5832 assert_eq!(state.brand().await.name, "Acme Corp");
5833
5834 state
5836 .settings_store()
5837 .save(&settings::BrandSetting {
5838 app_name: " ".to_string(),
5839 ..Default::default()
5840 })
5841 .await
5842 .unwrap();
5843 assert_eq!(state.brand().await.name, "Configured Name");
5844 }
5845
5846 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
5847 settings::SettingsItem {
5848 code: code.to_string(),
5849 label: code.into(),
5850 description: String::new().into(),
5851 category: "General".into(),
5852 hint: None,
5853 confirm_leave: true,
5854 slot: laterite_core::arrange::slot::APPLICATION,
5855 order: 1,
5856 icon: None,
5857 permission: permission.map(str::to_string),
5858 link: None,
5859 fields: Vec::new(),
5860 route: None,
5861 per_operator: false,
5862 }
5863 }
5864
5865 #[test]
5866 fn settings_visibility_respects_permissions() {
5867 let items = vec![
5868 settings_item("public", None),
5869 settings_item("gated", Some("backend.manage_users")),
5870 ];
5871
5872 let none = PermissionSet::new(false, Vec::<String>::new());
5874 let codes: Vec<String> = visible_settings(&items, &none)
5875 .into_iter()
5876 .map(|i| i.code)
5877 .collect();
5878 assert_eq!(codes, ["public"]);
5879
5880 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
5882 assert_eq!(visible_settings(&items, &granted).len(), 2);
5883
5884 let superuser = PermissionSet::new(true, Vec::<String>::new());
5886 assert_eq!(visible_settings(&items, &superuser).len(), 2);
5887 }
5888
5889 #[test]
5890 fn context_sidebar_follows_settings_links() {
5891 let items = builtin_settings();
5892 let superuser = PermissionSet::new(true, Vec::<String>::new());
5893 let tr = Translator::new("en");
5894 let tree = nav::NavTree::default();
5895 let sidebar = |path: &str| {
5896 resolve_nav_context(
5897 &tree,
5898 &items,
5899 "/admin",
5900 &superuser,
5901 None,
5902 path,
5903 &tr,
5904 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
5905 &std::collections::BTreeMap::new(),
5906 None,
5907 )
5908 .1
5909 };
5910 let active_path = |path: &str| -> Option<String> {
5911 sidebar(path)
5912 .into_iter()
5913 .flat_map(|g| g.items)
5914 .find(|i| i.active)
5915 .map(|i| i.path)
5916 };
5917
5918 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
5920 assert_eq!(
5921 active_path("/admin/roles/42/edit").as_deref(),
5922 Some("/admin/roles")
5923 );
5924 assert!(!sidebar("/admin/settings").is_empty());
5926 assert_eq!(active_path("/admin/settings"), None);
5927 assert_eq!(
5932 active_path("/admin/settings/branding").as_deref(),
5933 Some("/admin/settings/branding")
5934 );
5935 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
5938 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
5939 assert!(sidebar("/admin").is_empty());
5941 }
5942}
5943
5944#[cfg(test)]
5945mod session_cookie_tests {
5946 use super::*;
5947
5948 #[test]
5952 fn the_session_cookie_never_outlives_the_browser() {
5953 let cookie = session_cookie("tok".into(), "/admin", false);
5954 assert_eq!(cookie.max_age(), None);
5955 assert!(cookie.http_only().unwrap_or(false));
5956 assert_eq!(cookie.path(), Some("/admin"));
5957 }
5958
5959 #[test]
5962 fn the_remember_cookie_carries_its_own_lifetime() {
5963 let credential = laterite_auth::RememberCredential {
5964 cookie: "sel:ver".into(),
5965 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
5966 };
5967 let cookie = remember_cookie(credential, "/admin", true);
5968 let age = cookie.max_age().expect("a remember cookie must persist");
5969 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
5971 assert!(cookie.http_only().unwrap_or(false));
5972 assert!(cookie.secure().unwrap_or(false));
5973 }
5974
5975 #[test]
5977 fn the_removal_matches_the_cookie_it_clears() {
5978 let removal = remember_removal("/admin");
5979 assert_eq!(removal.name(), REMEMBER_COOKIE);
5980 assert_eq!(removal.path(), Some("/admin"));
5981 }
5982}