1mod audit;
18pub mod bootstrap;
19mod bulk;
20pub mod checklist;
21mod clientip;
22pub mod descriptor;
23mod error;
24mod export;
25pub mod field;
26pub mod form;
27pub mod html;
28pub mod http_cache;
29mod icons;
30pub mod import;
31pub(crate) mod keyed;
32pub mod list;
33pub mod persist;
34pub mod picker;
35pub mod plugins;
36pub mod relation;
37mod roles;
38mod routemap;
39pub mod routes;
40mod session;
41pub mod settings;
42mod sql;
43mod upload;
44mod users;
45
46pub use axum;
54
55pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
56pub use error::AdminError;
57pub use session::{FlashLevel, SessionHandle};
58pub use upload::VerifiedUpload;
59
60use std::collections::HashMap;
61use std::sync::{Arc, RwLock};
62
63use askama::Template;
64use axum::body::Body;
65use axum::extract::{Path, Query, Request, State};
66use axum::http::{header, HeaderMap};
67use axum::middleware::{self, Next};
68use axum::response::{Html, IntoResponse, Redirect, Response};
69use axum::routing::{any, get, post};
70use axum::{Extension, Form, Router};
71use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
72use chrono_tz::{Tz, TZ_VARIANTS};
73use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
74use laterite_core::{t, CatalogStore, Db, Text, Translator};
75use serde::{Deserialize, Serialize};
76
77pub trait AdminRegistry {
83 fn add_resource(&mut self, resource: Resource);
85 fn add_permission(&mut self, permission: Permission);
87 fn add_settings(&mut self, item: settings::SettingsItem);
89 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
91 fn add_persister(&mut self, persister: persist::PersisterReg);
93 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
95 fn add_screen(&mut self, screen: routes::ScreenReg);
98 fn add_public_route(&mut self, route: routes::PublicRouteReg);
101}
102
103impl AdminRegistry for laterite_core::Registry {
104 fn add_resource(&mut self, resource: Resource) {
105 self.add(resource);
106 }
107 fn add_permission(&mut self, permission: Permission) {
108 self.add(permission);
109 }
110 fn add_settings(&mut self, item: settings::SettingsItem) {
111 self.add(item);
112 }
113 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
114 self.add(source);
115 }
116 fn add_screen(&mut self, screen: routes::ScreenReg) {
117 self.add(screen);
118 }
119 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
120 self.add(route);
121 }
122 fn add_persister(&mut self, persister: persist::PersisterReg) {
123 self.add(persister);
124 }
125 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
126 self.add(field_type);
127 }
128}
129
130const SESSION_COOKIE: &str = "laterite_session";
131const REMEMBER_COOKIE: &str = "laterite_remember";
135
136pub(crate) const ICON_SPRITE: &str = "icons.svg";
139
140#[derive(Clone)]
142pub(crate) struct AdminState {
143 auth: AuthService,
144 db: Db,
145 nav: Arc<Vec<NavLink>>,
146 settings: Arc<Vec<settings::SettingsItem>>,
147 permissions: Arc<Vec<Permission>>,
148 admin_path: Arc<str>,
152 secure_cookie: bool,
153 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
155 icons: Arc<laterite_core::icons::IconSet>,
157 sprite_url: Arc<str>,
159 origin: Arc<str>,
161 timezone: Tz,
162 default_locale: Arc<str>,
166 catalogs: Arc<CatalogStore>,
169 app_name: String,
172 brand_cache: Arc<RwLock<Option<String>>>,
176 asset_urls: Arc<AssetUrls>,
178 field_types: Arc<field::FieldRegistry>,
181 column_types: Arc<list::ColumnRegistry>,
184 plugin_defined: Arc<laterite_core::Registry>,
187 assets: Arc<AssetRegistry>,
189 pickers: Arc<picker::PickerRegistry>,
192 overrides: Arc<dyn field::OverrideResolver>,
196}
197
198impl AdminState {
199 #[cfg(test)]
200 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
201 Self {
202 auth,
203 db,
204 nav: Arc::new(Vec::new()),
205 settings: Arc::new(Vec::new()),
206 permissions: Arc::new(builtin_permissions()),
207 trusted_proxies: Arc::new(Vec::new()),
208 icons: Arc::new(laterite_core::icons::IconSet::new()),
209 sprite_url: Arc::from(""),
210 admin_path: Arc::from("/admin"),
211 secure_cookie: false,
212 origin: Arc::from(""),
213 timezone: Tz::UTC,
214 default_locale: Arc::from("en"),
215 catalogs: Arc::new(CatalogStore::default()),
216 app_name: "Laterite".to_string(),
217 brand_cache: Arc::new(RwLock::new(None)),
218 field_types: Arc::new(field::builtin_registry()),
219 column_types: Arc::new(list::builtin_column_registry()),
220 plugin_defined: Arc::new(laterite_core::Registry::new()),
221 assets: Arc::new(builtin_assets()),
222 asset_urls: Arc::new(asset_urls(&builtin_assets())),
223 pickers: Arc::new(picker::PickerRegistry::new()),
224 overrides: Arc::new(field::NoOverrides),
225 }
226 }
227
228 async fn brand(&self) -> String {
232 {
233 let cached = self.brand_cache.read().unwrap().clone();
234 if let Some(name) = cached {
235 return name;
236 }
237 }
238 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
239 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
240 _ => self.app_name.clone(),
241 };
242 *self.brand_cache.write().unwrap() = Some(resolved.clone());
243 resolved
244 }
245
246 fn invalidate_brand(&self) {
248 *self.brand_cache.write().unwrap() = None;
249 }
250}
251
252#[derive(Clone)]
258#[non_exhaustive]
259pub struct AdminConfig {
260 pub secure_cookie: bool,
263 pub trusted_proxies: Vec<String>,
267 pub timezone: String,
271 pub locale: String,
275 pub app_name: String,
279 pub path: String,
283 pub origin: String,
288 pub storage_dir: String,
293}
294
295impl Default for AdminConfig {
296 fn default() -> Self {
297 Self {
298 secure_cookie: false,
299 trusted_proxies: Vec::new(),
300 timezone: "UTC".to_string(),
301 locale: "en".to_string(),
302 app_name: "Laterite".to_string(),
303 path: "/admin".to_string(),
304 origin: String::new(),
305 storage_dir: "storage".to_string(),
306 }
307 }
308}
309
310#[derive(Clone)]
311struct NavLink {
312 label: Text,
313 path: String,
314 icon: Option<&'static str>,
317}
318
319#[derive(Clone)]
328pub struct Shell {
329 pub(crate) base: String,
333 brand: String,
337 nav: Vec<NavView>,
338 full_name: String,
339 initial: String,
340 tz: Tz,
344 i18n: Translator,
348 sidebar: Vec<settings::CategoryView>,
352 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
354 pub(crate) sprite_url: Arc<str>,
357 site_url: String,
361 asset_urls: Arc<AssetUrls>,
365 pub(crate) csrf_token: String,
369 pub(crate) flash: Vec<FlashLine>,
373 pub(crate) assets: Vec<PageAsset>,
377}
378
379#[derive(Template)]
381#[template(path = "page.html")]
382struct PageTemplate {
383 title: String,
384 body: html::Markup,
385 shell: Shell,
386}
387
388#[derive(Clone)]
390pub(crate) struct PageAsset {
391 pub(crate) url: String,
392 pub(crate) css: bool,
393}
394
395#[derive(Clone)]
398pub(crate) struct FlashLine {
399 pub(crate) level: session::FlashLevel,
400 pub(crate) text: String,
401 pub(crate) sticky: bool,
403}
404
405impl Shell {
406 #[allow(clippy::too_many_arguments)]
407 fn new(
408 base: &str,
409 brand: String,
410 nav: &[NavLink],
411 user: &AuthenticatedUser,
412 default_tz: Tz,
413 sidebar: Vec<settings::CategoryView>,
414 active_nav: Option<&str>,
415 csrf_token: String,
416 flash: Vec<session::Flash>,
417 i18n: Translator,
418 asset_urls: Arc<AssetUrls>,
419 site_url: String,
420 icon_set: Arc<laterite_core::icons::IconSet>,
421 sprite_url: Arc<str>,
422 ) -> Self {
423 let full_name = user.user.full_name();
424 let initial = full_name
425 .chars()
426 .next()
427 .map(|c| c.to_uppercase().to_string())
428 .unwrap_or_else(|| "?".to_string());
429 let flash = flash
432 .into_iter()
433 .map(|f| FlashLine {
434 sticky: f.sticky,
435 level: f.level,
436 text: i18n.t(&f.text),
437 })
438 .collect();
439 let nav = nav
440 .iter()
441 .map(|n| NavView {
442 label: i18n.t(&n.label),
443 path: n.path.clone(),
444 active: active_nav == Some(n.path.as_str()),
445 icon: n
446 .icon
447 .map(|name| icons::svg(&icon_set, &sprite_url, Some(name)))
448 .unwrap_or_default(),
449 })
450 .collect();
451 Shell {
452 asset_urls,
453 icon_set,
454 sprite_url,
455 site_url,
456 base: base.to_string(),
457 brand,
458 nav,
459 full_name,
460 initial,
461 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
462 i18n,
463 sidebar,
464 csrf_token,
465 flash,
466 assets: Vec::new(),
467 }
468 }
469
470 pub(crate) fn site_url(&self) -> &str {
472 &self.site_url
473 }
474
475 pub(crate) fn icon(&self, name: &str) -> String {
477 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
478 }
479
480 pub(crate) fn asset(&self, key: &str) -> String {
485 match self.asset_urls.get(key) {
486 Some(path) => format!("{}/assets/{path}", self.base),
487 None => {
488 debug_assert!(false, "asset key `{key}` is not registered");
489 format!("{}/assets/{key}", self.base)
490 }
491 }
492 }
493
494 pub fn t(&self, source: &str) -> String {
497 self.i18n.t(&Text::dynamic(source))
498 }
499
500 pub fn tt(&self, text: &Text) -> String {
503 self.i18n.t(text)
504 }
505
506 pub fn i18n(&self) -> &Translator {
509 &self.i18n
510 }
511
512 pub fn locale(&self) -> &str {
514 self.i18n.locale()
515 }
516
517 pub fn base(&self) -> &str {
519 &self.base
520 }
521
522 pub fn csrf_token(&self) -> &str {
526 &self.csrf_token
527 }
528
529 pub fn datetime(&self, utc: &str) -> String {
532 match chrono::DateTime::parse_from_rfc3339(utc) {
533 Ok(at) => at
534 .with_timezone(&self.tz)
535 .format("%Y-%m-%d %H:%M")
536 .to_string(),
537 Err(_) => utc.to_string(),
538 }
539 }
540
541 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
545 render(PageTemplate {
546 title: self.tt(title),
547 body,
548 shell: self.clone(),
549 })
550 }
551
552 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
556 let mut text = Text::dynamic(source);
557 for (name, value) in args {
558 text = text.arg(*name, *value);
559 }
560 self.i18n.t(&text)
561 }
562
563 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
566 let mut text = Text::dynamic(source);
567 for (name, value) in args {
568 text = text.arg(*name, *value);
569 }
570 self.i18n.t(&text)
571 }
572
573 #[cfg(test)]
574 pub(crate) fn test() -> Self {
575 Shell {
576 asset_urls: Arc::new(asset_urls(&builtin_assets())),
577 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
578 sprite_url: Arc::from("/admin/assets/icons.svg"),
579 site_url: "http://localhost".to_string(),
580 base: "/admin".to_string(),
581 brand: "Laterite".to_string(),
582 nav: Vec::new(),
583 full_name: "Test Operator".to_string(),
584 initial: "T".to_string(),
585 tz: Tz::UTC,
586 i18n: Translator::new("en"),
587 sidebar: Vec::new(),
588 csrf_token: "test-csrf-token".to_string(),
589 flash: Vec::new(),
590 assets: Vec::new(),
591 }
592 }
593}
594
595fn settings_context(
602 visible: &[settings::SettingsItem],
603 admin_path: &str,
604 path: &str,
605) -> (bool, Option<String>) {
606 if path == format!("{admin_path}/settings") {
607 return (true, None);
608 }
609 let own = visible
615 .iter()
616 .filter(|i| i.link.is_none())
617 .find(|i| path == format!("{admin_path}{}", i.route()))
618 .map(|i| i.code.clone());
619 if own.is_some() {
620 return (true, own);
621 }
622 let linked = visible
623 .iter()
624 .filter_map(|i| {
625 i.link
626 .as_deref()
627 .map(|link| (format!("{admin_path}{link}"), &i.code))
628 })
629 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
630 .max_by_key(|(link, _)| link.len())
631 .map(|(_, code)| code.clone());
632 (linked.is_some(), linked)
633}
634
635fn active_nav_path(
643 nav: &[NavLink],
644 admin_path: &str,
645 in_settings_context: bool,
646 path: &str,
647) -> Option<String> {
648 if in_settings_context {
649 return Some(format!("{admin_path}/settings"));
650 }
651 nav.iter()
652 .filter(|n| {
653 path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
654 })
655 .max_by_key(|n| n.path.len())
656 .map(|n| n.path.clone())
657}
658
659fn resolve_nav_context(
664 nav: &[NavLink],
665 items: &[settings::SettingsItem],
666 admin_path: &str,
667 perms: &PermissionSet,
668 path: &str,
669 tr: &Translator,
670 icons: icons::Icons<'_>,
671) -> (Vec<settings::CategoryView>, Option<String>) {
672 let visible = visible_settings(items, perms);
673 let (in_context, active) = settings_context(&visible, admin_path, path);
674 let sidebar = if in_context {
675 settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons)
676 } else {
677 Vec::new()
678 };
679 let active_nav = active_nav_path(nav, admin_path, in_context, path);
680 (sidebar, active_nav)
681}
682
683fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
687 preference
688 .and_then(|name| name.parse::<Tz>().ok())
689 .unwrap_or(default_tz)
690}
691
692fn zone_offered(name: &str) -> bool {
694 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
695}
696
697fn base_lang(tag: &str) -> String {
700 tag.split(['-', '_'])
701 .next()
702 .unwrap_or(tag)
703 .to_ascii_lowercase()
704}
705
706fn is_serveable(base: &str, serveable: &[String]) -> bool {
712 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
713}
714
715fn default_locale(configured: &str, serveable: &[String]) -> String {
718 let base = base_lang(configured);
719 if is_serveable(&base, serveable) {
720 base
721 } else {
722 "en".to_string()
723 }
724}
725
726fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
730 let mut out = vec!["en".to_string()];
731 out.extend(catalogs.locales());
732 out
733}
734
735fn locale_name(code: &str) -> String {
740 if code == "en" {
741 "English".to_string()
742 } else {
743 code.to_string()
744 }
745}
746
747fn parse_accept_language(header: &str) -> Vec<String> {
750 let mut items: Vec<(f32, String)> = header
751 .split(',')
752 .filter_map(|part| {
753 let mut bits = part.split(';');
754 let tag = bits.next()?.trim();
755 if tag.is_empty() || tag == "*" {
756 return None;
757 }
758 let q = bits
759 .find_map(|p| p.trim().strip_prefix("q="))
760 .and_then(|q| q.parse::<f32>().ok())
761 .unwrap_or(1.0);
762 Some((q, tag.to_string()))
763 })
764 .collect();
765 items.sort_by(|a, b| b.0.total_cmp(&a.0));
767 items.into_iter().map(|(_, tag)| tag).collect()
768}
769
770fn resolve_locale_chain(
776 preference: Option<&str>,
777 accept_language: Option<&str>,
778 default: &str,
779 serveable: &[String],
780) -> Vec<String> {
781 let mut chain: Vec<String> = Vec::new();
782 let consider = |tag: &str, chain: &mut Vec<String>| {
783 let base = base_lang(tag);
784 if is_serveable(&base, serveable) && !chain.contains(&base) {
785 chain.push(base);
786 }
787 };
788 if let Some(pref) = preference {
789 consider(pref, &mut chain);
790 }
791 if let Some(header) = accept_language {
792 for tag in parse_accept_language(header) {
793 consider(&tag, &mut chain);
794 }
795 }
796 consider(default, &mut chain);
797 consider("en", &mut chain);
800 chain
801}
802
803fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
807 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
808 let serveable = state.catalogs.locales();
809 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
810 Translator::with_chain(chain, state.catalogs.clone())
811}
812
813#[derive(Serialize)]
821#[non_exhaustive]
822pub struct Resource {
823 pub base_path: String,
829 pub nav_label: Text,
831 pub list: list::ListConfig,
832 pub form: Option<form::FormConfig>,
833 pub permission: Option<String>,
840 pub import: Option<ImportConfig>,
843 pub relations: Vec<relation::RelationConfig>,
846}
847
848#[derive(Clone, Debug, Default, Serialize, Deserialize)]
851#[non_exhaustive]
852pub struct ImportConfig {
853 #[serde(default)]
856 pub permission: Option<String>,
857}
858
859impl ImportConfig {
860 pub fn new() -> Self {
861 Self::default()
862 }
863
864 pub fn permission(mut self, code: impl Into<String>) -> Self {
867 self.permission = Some(code.into());
868 self
869 }
870}
871
872#[derive(Clone, Serialize)]
877#[non_exhaustive]
878pub struct Permission {
879 pub code: String,
880 pub label: Text,
882 pub group: Text,
883 pub default_roles: Vec<String>,
886 pub renamed_from: Vec<String>,
889}
890
891pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
892
893impl Permission {
894 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
897 Self {
898 code: code.into(),
899 label: label.into(),
900 group: group.into(),
901 default_roles: Vec::new(),
902 renamed_from: Vec::new(),
903 }
904 }
905
906 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
912 where
913 I: IntoIterator<Item = S>,
914 S: Into<String>,
915 {
916 self.renamed_from = codes.into_iter().map(Into::into).collect();
917 self
918 }
919
920 pub fn roles<I, S>(mut self, roles: I) -> Self
923 where
924 I: IntoIterator<Item = S>,
925 S: Into<String>,
926 {
927 self.default_roles = roles.into_iter().map(Into::into).collect();
928 self
929 }
930}
931
932pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
938 let permissions: Vec<Permission> = builtin_permissions()
939 .into_iter()
940 .chain(contributed.iter().cloned())
941 .collect();
942 let mut out: Vec<(String, String)> = Vec::new();
943 for permission in &permissions {
944 for old in &permission.renamed_from {
945 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
946 panic!(
947 "permissions `{taken}` and `{}` both claim the old code `{old}`",
948 permission.code
949 );
950 }
951 if permissions.iter().any(|p| &p.code == old) {
952 panic!(
953 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
954 permission.code
955 );
956 }
957 out.push((old.clone(), permission.code.clone()));
958 }
959 }
960 out
961}
962
963pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
969 let permissions: Vec<Permission> = builtin_permissions()
972 .into_iter()
973 .chain(contributed.iter().cloned())
974 .collect();
975 let held = |role: &str| -> Vec<String> {
976 permissions
977 .iter()
978 .filter(|p| {
979 if p.default_roles.is_empty() {
980 role == ROLE_ADMIN
981 } else {
982 p.default_roles.iter().any(|r| r == role)
983 }
984 })
985 .map(|p| p.code.clone())
986 .collect()
987 };
988 vec![
989 laterite_auth::store::SystemRole {
990 code: ROLE_ADMIN,
991 name: "Administrator",
992 description: "Administers the panel itself: operators, roles and settings.",
993 permissions: held(ROLE_ADMIN),
994 },
995 laterite_auth::store::SystemRole {
996 code: ROLE_EDITOR,
997 name: "Editor",
998 description: "Works with the content the application declares.",
999 permissions: held(ROLE_EDITOR),
1000 },
1001 ]
1002}
1003
1004impl Resource {
1005 pub fn new(
1010 base_path: impl Into<String>,
1011 nav_label: impl Into<Text>,
1012 list: list::ListConfig,
1013 ) -> Self {
1014 Self {
1015 base_path: base_path.into(),
1016 nav_label: nav_label.into(),
1017 list,
1018 form: None,
1019 permission: None,
1020 import: None,
1021 relations: Vec::new(),
1022 }
1023 }
1024
1025 pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
1027 self.relations.push(relation);
1028 self
1029 }
1030
1031 pub fn import(mut self, import: ImportConfig) -> Self {
1033 self.import = Some(import);
1034 self
1035 }
1036
1037 pub fn form(mut self, form: form::FormConfig) -> Self {
1040 self.form = Some(form);
1041 self
1042 }
1043
1044 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1046 self.permission = Some(permission.into());
1047 self
1048 }
1049}
1050
1051struct SystemRoleGuard;
1057
1058#[laterite_core::strata::async_trait]
1059impl laterite_core::ModelListener for SystemRoleGuard {
1060 async fn before_delete(
1061 &self,
1062 cx: &mut laterite_core::SaveCx<'_>,
1063 rec: &laterite_core::Record,
1064 ) -> Result<(), Text> {
1065 let Some(id) = rec.id() else {
1066 return Ok(());
1067 };
1068 let backend = cx.backend();
1070 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1071 Ok(true) => Err(t!(
1072 "The framework maintains this role; it cannot be deleted."
1073 )),
1074 Err(_) => Err(t!(
1076 "Could not check whether this role is maintained by the framework."
1077 )),
1078 Ok(false) => Ok(()),
1079 }
1080 }
1081}
1082
1083fn builtin_permissions() -> Vec<Permission> {
1086 vec![
1089 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1090 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1091 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1092 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1093 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1094 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1095 ]
1096}
1097
1098pub fn descriptor_sources() -> Vec<String> {
1104 let mut out = Vec::new();
1105 out.extend(laterite_core::collect_sources(&builtin_resources()));
1106 out.extend(laterite_core::collect_sources(&builtin_settings()));
1107 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1108 out.sort();
1109 out.dedup();
1110 out
1111}
1112
1113pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1128 builtin_modules().iter().map(|m| m.migrations()).collect()
1129}
1130
1131pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1135 vec![
1136 Box::new(laterite_auth::AuthModule),
1137 Box::new(settings::SettingsModule),
1138 Box::new(plugins::PluginsModule),
1139 Box::new(laterite_media::MediaModule),
1140 Box::new(import::ImportModule),
1141 ]
1142}
1143
1144#[derive(Default)]
1168pub struct Contributions {
1169 pub resources: Vec<Resource>,
1171 pub settings: Vec<settings::SettingsItem>,
1173 pub permissions: Vec<Permission>,
1175 pub picker_sources: Vec<picker::PickerSourceReg>,
1177 pub persisters: Vec<persist::PersisterReg>,
1179 pub listeners: Vec<laterite_core::ModelListenerReg>,
1181 pub field_types: Vec<field::FieldTypeReg>,
1185 pub column_types: Vec<list::ColumnTypeReg>,
1186 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1189 pub screens: Vec<routes::ScreenReg>,
1191 pub public_routes: Vec<routes::PublicRouteReg>,
1193 pub plugin_defined: Arc<laterite_core::Registry>,
1198}
1199
1200pub fn router(
1201 auth: AuthService,
1202 db: Db,
1203 contributions: Contributions,
1204 config: AdminConfig,
1205 catalogs: Arc<CatalogStore>,
1206) -> Router {
1207 let Contributions {
1208 resources: app_resources,
1209 settings: app_settings,
1210 permissions: app_permissions,
1211 picker_sources: app_picker_sources,
1212 persisters: app_persisters,
1213 listeners: app_listeners,
1214 field_types: app_field_types,
1215 column_types: app_column_types,
1216 icons: app_icons,
1217 screens: app_screens,
1218 public_routes: app_public,
1219 plugin_defined,
1220 } = contributions;
1221 let admin_path = normalize_path(&config.path);
1222
1223 let mut resources = builtin_resources();
1224 let mut app_resources = app_resources;
1225 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1229 prefix_resource(&admin_path, resource);
1230 }
1231 for resource in app_resources.iter_mut() {
1235 if let Some(import) = &resource.import {
1236 let permission = import
1237 .permission
1238 .clone()
1239 .or_else(|| resource.permission.clone())
1240 .unwrap_or_else(|| {
1241 panic!(
1242 "admin resource `{}` declares `import` but no permission; importing \
1243 writes records, so it must be gated",
1244 resource.base_path
1245 )
1246 });
1247 resource.list.import = Some(routes::feature_link(
1248 &resource.base_path,
1249 "import",
1250 &permission,
1251 ));
1252 }
1253 }
1254
1255 let mut settings = builtin_settings();
1256 settings.extend(app_settings);
1257 let mut app_listeners = app_listeners;
1260 app_listeners.insert(
1261 0,
1262 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1263 );
1264
1265 let mut permissions = builtin_permissions();
1266 permissions.extend(app_permissions);
1267 let permission_codes: std::collections::HashSet<String> =
1269 permissions.iter().map(|p| p.code.clone()).collect();
1270
1271 let mut nav = vec![NavLink {
1275 label: "Dashboard".into(),
1276 path: admin_path.clone(),
1277 icon: Some("layout-dashboard"),
1278 }];
1279 for resource in &app_resources {
1280 nav.push(NavLink {
1281 label: resource.nav_label.clone(),
1282 path: resource.base_path.clone(),
1283 icon: None,
1284 });
1285 }
1286 for reg in &app_screens {
1289 if let Some(label) = ®.nav_label {
1290 nav.push(NavLink {
1291 label: label.clone(),
1292 path: format!("{admin_path}{}", reg.base_path),
1293 icon: None,
1294 });
1295 }
1296 }
1297 nav.push(NavLink {
1298 label: "Settings".into(),
1299 path: format!("{admin_path}/settings"),
1300 icon: Some("settings"),
1301 });
1302 resources.extend(app_resources);
1303
1304 let app_name = if config.app_name.trim().is_empty() {
1305 "Laterite".to_string()
1306 } else {
1307 config.app_name.clone()
1308 };
1309
1310 let mut pickers = picker::PickerRegistry::new();
1313 for reg in app_picker_sources {
1314 let name = reg.name.clone();
1315 if !field::is_name(&name, true) {
1316 panic!("invalid picker source name `{name}`");
1317 }
1318 if pickers.insert(name.clone(), reg).is_some() {
1319 panic!("duplicate picker source `{name}`");
1320 }
1321 }
1322
1323 let pickers = Arc::new(pickers);
1327 let mut field_types = field::builtin_registry();
1328 field_types.insert(
1329 "reference".to_string(),
1330 Arc::new(field::RefPickerField::new(pickers.clone())),
1331 );
1332
1333 let mut persisters = persist::PersisterRegistry::new();
1336 for reg in app_persisters {
1337 let name = reg.name.clone();
1338 if !field::is_name(&name, true) {
1339 panic!("invalid persister name `{name}`");
1340 }
1341 if persisters.insert(name.clone(), reg.persister).is_some() {
1342 panic!("duplicate persister `{name}`");
1343 }
1344 }
1345
1346 for reg in app_field_types {
1352 let name = reg.name().to_string();
1353 if !field::is_name(&name, true) {
1354 panic!("invalid field type name `{name}`");
1355 }
1356 if field_types.insert(name.clone(), reg.field_type).is_some() {
1357 panic!("duplicate field type `{name}`");
1358 }
1359 }
1360
1361 let mut icon_set = laterite_core::icons::IconSet::new();
1365 for (owner, reg) in app_icons {
1366 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1367 panic!("module `{owner}` contributed an invalid icon: {e}");
1368 }
1369 }
1370
1371 let mut assets = builtin_assets();
1374 assets.insert(
1375 ICON_SPRITE,
1376 AdminAsset {
1377 mime: "image/svg+xml",
1378 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1379 },
1380 );
1381
1382 for item in &settings {
1386 icons::require(
1387 &icon_set,
1388 &format!("settings item `{}`", item.code),
1389 item.icon.as_deref(),
1390 );
1391 }
1392 for link in &nav {
1393 icons::require(&icon_set, &format!("menu entry `{}`", link.path), link.icon);
1394 }
1395
1396 let mut column_types = list::builtin_column_registry();
1400 for reg in app_column_types {
1401 let name = reg.name().to_string();
1402 if !field::is_name(&name, true) {
1403 panic!("invalid column type name `{name}`");
1404 }
1405 if column_types.insert(name.clone(), reg.column_type).is_some() {
1406 panic!("duplicate column type `{name}`");
1407 }
1408 }
1409
1410 let settings = Arc::new(settings);
1411 let asset_urls_arc = Arc::new(asset_urls(&assets));
1413 let sprite_url: Arc<str> = Arc::from(
1414 asset_urls_arc
1415 .get(ICON_SPRITE)
1416 .map(|p| format!("{admin_path}/assets/{p}"))
1417 .unwrap_or_default()
1418 .as_str(),
1419 );
1420 let icons_arc = Arc::new(icon_set);
1421 let state = AdminState {
1422 auth,
1423 db,
1424 nav: Arc::new(nav),
1425 settings: settings.clone(),
1426 permissions: Arc::new(permissions),
1427 admin_path: Arc::from(admin_path.as_str()),
1428 secure_cookie: config.secure_cookie,
1429 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1430 icons: icons_arc.clone(),
1431 sprite_url: sprite_url.clone(),
1432 origin: Arc::from(config.origin.trim_end_matches('/')),
1433 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1434 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1435 catalogs,
1436 app_name,
1437 brand_cache: Arc::new(RwLock::new(None)),
1438 field_types: Arc::new(field_types),
1439 column_types: Arc::new(column_types),
1440 plugin_defined,
1441 assets: Arc::new(assets),
1442 asset_urls: asset_urls_arc.clone(),
1443 pickers,
1444 overrides: Arc::new(field::NoOverrides),
1445 };
1446
1447 let mut protected = Router::new().route(&admin_path, get(dashboard));
1448 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
1452 for resource in &resources {
1453 let (mounted, prepared) = mount_resource(
1454 resource,
1455 &state.field_types,
1456 &state.column_types,
1457 &permission_codes,
1458 &persisters,
1459 &app_listeners,
1460 );
1461 protected = protected.merge(mounted);
1462 if let Some(prepared) = prepared {
1463 let key = resource
1464 .base_path
1465 .strip_prefix(admin_path.as_str())
1466 .unwrap_or(&resource.base_path);
1467 forms.insert(key.to_string(), prepared);
1468 }
1469 }
1470 let forms: form::Forms = Arc::new(forms);
1471 let private_disk: Arc<dyn laterite_media::StorageDriver> = state
1475 .plugin_defined
1476 .items::<laterite_media::DiskReg>()
1477 .into_iter()
1478 .find(|d| d.name == import::DISK)
1479 .map(|d| d.driver.clone())
1480 .unwrap_or_else(|| {
1481 Arc::new(laterite_media::LocalDisk::new(format!(
1482 "{}/{}",
1483 config.storage_dir.trim_end_matches('/'),
1484 import::DISK
1485 )))
1486 });
1487 for resource in &resources {
1488 let Some(link) = &resource.list.import else {
1489 continue;
1490 };
1491 let key = resource
1492 .base_path
1493 .strip_prefix(admin_path.as_str())
1494 .unwrap_or(&resource.base_path)
1495 .to_string();
1496 let prepared = forms.get(&key).unwrap_or_else(|| {
1497 panic!(
1498 "admin resource `{}` declares `import` but has no form to import through",
1499 resource.base_path
1500 )
1501 });
1502 let feature = import::ImportFeature {
1503 disk: private_disk.clone(),
1504 };
1505 let base = link.href.clone();
1506 let ctx = routes::RouteCtx::new(
1507 state.db.clone(),
1508 &base,
1509 &admin_path,
1510 &state.origin,
1511 state.plugin_defined.clone(),
1512 state.auth.events().clone(),
1513 forms.clone(),
1514 );
1515 let info = routes::ResourceInfo::new(
1516 key,
1517 resource.base_path.clone(),
1518 resource.nav_label.clone(),
1519 link.permission.clone(),
1520 form::FormHandle::new(state.db.clone(), prepared.clone()),
1521 );
1522 protected = protected.merge(guard_with_permission(
1523 Router::new()
1524 .nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
1525 &link.permission,
1526 ));
1527 }
1528 for reg in &app_screens {
1532 let base = format!("{admin_path}{}", reg.base_path);
1533 let ctx = routes::RouteCtx::new(
1534 state.db.clone(),
1535 &base,
1536 &admin_path,
1537 &state.origin,
1538 state.plugin_defined.clone(),
1539 state.auth.events().clone(),
1540 forms.clone(),
1541 );
1542 protected = protected.merge(guard_with_permission(
1543 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1544 ®.permission,
1545 ));
1546 }
1547
1548 protected = protected.merge(guard_with_permission(
1551 Router::new()
1552 .route(
1553 &format!("{admin_path}/roles/new"),
1554 get(roles::new_form).post(roles::create),
1555 )
1556 .route(
1557 &format!("{admin_path}/roles/{{id}}/edit"),
1558 get(roles::edit_form).post(roles::update),
1559 ),
1560 "backend.manage_roles",
1561 ));
1562 protected = protected.merge(guard_with_permission(
1565 Router::new()
1566 .route(
1567 &format!("{admin_path}/users/new"),
1568 get(users::new_form).post(users::create),
1569 )
1570 .route(
1571 &format!("{admin_path}/users/{{id}}/edit"),
1572 get(users::edit_form).post(users::update),
1573 )
1574 .route(
1575 &format!("{admin_path}/users/{{id}}/active"),
1576 post(users::set_active),
1577 )
1578 .route(
1579 &format!("{admin_path}/users/{{id}}/password"),
1580 post(users::reset_password),
1581 )
1582 .route(
1583 &format!("{admin_path}/users/{{id}}/unlock"),
1584 post(users::unlock),
1585 ),
1586 "backend.manage_users",
1587 ));
1588 protected = protected.merge(guard_with_permission(
1591 Router::new()
1592 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1593 .route(
1594 &format!("{admin_path}/plugins/toggle"),
1595 post(plugins::toggle),
1596 ),
1597 plugins::MANAGE_PERMISSION,
1598 ));
1599 protected = protected
1600 .route(&format!("{admin_path}/settings"), get(settings_index))
1601 .route(
1602 &format!("{admin_path}/preferences"),
1603 get(preferences_form).post(preferences_update),
1604 )
1605 .route(
1606 &format!("{admin_path}/preferences/password"),
1607 post(password_update),
1608 )
1609 .route(
1610 &format!("{admin_path}/preferences/sessions/revoke"),
1611 post(session_revoke),
1612 )
1613 .route(
1614 &format!("{admin_path}/preferences/sessions/revoke-others"),
1615 post(session_revoke_others),
1616 )
1617 .route(
1620 &format!("{admin_path}/pickers/{{source}}/search"),
1621 any(picker::search),
1622 )
1623 .route(
1624 &format!("{admin_path}/pickers/{{source}}/resolve"),
1625 any(picker::resolve),
1626 )
1627 .route(&format!("{admin_path}/logout"), post(logout));
1628
1629 for item in settings.iter().filter(|i| i.link.is_none()) {
1633 let path = format!("{admin_path}{}", item.route());
1634 let code = item.code.clone();
1635 let edit_code = code.clone();
1636 protected = protected.route(
1637 &path,
1638 get(
1639 move |State(state): State<AdminState>,
1640 Extension(shell): Extension<Shell>,
1641 Extension(user): Extension<AuthenticatedUser>| {
1642 let code = edit_code.clone();
1643 async move { settings_edit(state, shell, user, code).await }
1644 },
1645 )
1646 .post(
1647 move |State(state): State<AdminState>,
1648 Extension(shell): Extension<Shell>,
1649 Extension(user): Extension<AuthenticatedUser>,
1650 Extension(session): Extension<session::SessionHandle>,
1651 Form(data): Form<HashMap<String, String>>| {
1652 let code = code.clone();
1653 async move { settings_update(state, shell, user, session, code, data).await }
1654 },
1655 ),
1656 );
1657 }
1658
1659 protected
1660 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1661 .route(
1665 &format!("{admin_path}/login"),
1666 get(login_form).post(login_submit),
1667 )
1668 .route(
1669 &format!("{admin_path}/setup"),
1670 get(setup_form).post(setup_submit),
1671 )
1672 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1673 .merge(mount_public(
1677 &app_public,
1678 &state.db,
1679 &admin_path,
1680 &state.origin,
1681 &state.plugin_defined,
1682 state.auth.events(),
1683 &forms,
1684 ))
1685 .fallback(not_found_fallback)
1687 .layer(middleware::from_fn_with_state(
1690 state.clone(),
1691 enforce_origin,
1692 ))
1693 .layer(middleware::from_fn_with_state(
1696 state.clone(),
1697 capture_client,
1698 ))
1699 .layer(
1704 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1705 header::CACHE_CONTROL,
1706 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1707 ),
1708 )
1709 .layer(
1713 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1714 header::VARY,
1715 axum::http::HeaderValue::from_static("HX-Request"),
1716 ),
1717 )
1718 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1719 handle_panic,
1720 ))
1721 .with_state(state)
1722}
1723
1724async fn not_found_fallback() -> AdminError {
1726 AdminError::NotFound
1727}
1728
1729fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1731 let cause = err
1732 .downcast_ref::<&str>()
1733 .map(|s| s.to_string())
1734 .or_else(|| err.downcast_ref::<String>().cloned())
1735 .unwrap_or_else(|| "unknown panic".to_string());
1736 tracing::error!(panic = cause, "admin handler panicked");
1737 error::masked_500()
1738}
1739
1740pub fn normalize_path(path: &str) -> String {
1745 let trimmed = path.trim().trim_matches('/');
1746 if trimmed.is_empty() {
1747 "/admin".to_string()
1748 } else {
1749 format!("/{trimmed}")
1750 }
1751}
1752
1753fn mount_public(
1757 regs: &[routes::PublicRouteReg],
1758 db: &Db,
1759 admin_path: &str,
1760 origin: &str,
1761 plugin_defined: &Arc<laterite_core::Registry>,
1762 events: &laterite_core::Events,
1763 forms: &form::Forms,
1764) -> Router<AdminState> {
1765 let mut router = Router::new();
1766 for reg in regs {
1767 let ctx = routes::RouteCtx::new(
1768 db.clone(),
1769 ®.path,
1770 admin_path,
1771 origin,
1772 plugin_defined.clone(),
1773 events.clone(),
1774 forms.clone(),
1775 );
1776 router = router.nest_service(
1781 ®.path,
1782 reg.route.mount(&ctx).fallback(not_found_fallback),
1783 );
1784 }
1785 router
1786}
1787
1788pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1791 let old = resource.base_path.clone();
1792 resource.base_path = base.to_string();
1793 if let Some(edit_base) = &mut resource.list.edit_base {
1794 if edit_base == &old {
1795 *edit_base = base.to_string();
1796 }
1797 }
1798 if let Some(form) = &mut resource.form {
1799 if form.base_path == old {
1800 form.base_path = base.to_string();
1801 }
1802 }
1803}
1804
1805fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1806 resource.base_path = format!("{admin_path}{}", resource.base_path);
1807 if let Some(edit_base) = &mut resource.list.edit_base {
1808 *edit_base = format!("{admin_path}{edit_base}");
1809 }
1810 if let Some(form) = &mut resource.form {
1811 form.base_path = format!("{admin_path}{}", form.base_path);
1812 }
1813}
1814
1815pub(crate) struct AdminAsset {
1817 pub mime: &'static str,
1818 pub bytes: std::borrow::Cow<'static, [u8]>,
1822}
1823
1824pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1827
1828pub(crate) type AssetUrls = HashMap<&'static str, String>;
1832
1833pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
1835 registry
1836 .iter()
1837 .map(|(&key, asset)| {
1838 (
1839 key,
1840 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
1841 )
1842 })
1843 .collect()
1844}
1845
1846pub(crate) fn page_assets(
1853 keys: &[&str],
1854 base: &str,
1855 registry: &AssetRegistry,
1856 urls: &AssetUrls,
1857) -> Vec<PageAsset> {
1858 let mut seen = std::collections::HashSet::new();
1859 let mut out = Vec::new();
1860 for &key in keys {
1861 if !seen.insert(key) {
1862 continue;
1863 }
1864 match (registry.get(key), urls.get(key)) {
1865 (Some(asset), Some(path)) => out.push(PageAsset {
1866 url: format!("{base}/assets/{path}"),
1867 css: asset.mime.contains("css"),
1868 }),
1869 _ => debug_assert!(false, "asset key `{key}` is not registered"),
1870 }
1871 }
1872 out
1873}
1874
1875pub(crate) fn builtin_assets() -> AssetRegistry {
1884 let font = |bytes: &'static [u8]| AdminAsset {
1885 mime: "font/woff2",
1886 bytes: std::borrow::Cow::Borrowed(bytes),
1887 };
1888 HashMap::from([
1889 (
1890 "laterite.css",
1891 AdminAsset {
1892 mime: "text/css; charset=utf-8",
1893 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
1894 },
1895 ),
1896 (
1897 "laterite.js",
1898 AdminAsset {
1899 mime: "text/javascript; charset=utf-8",
1900 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
1901 },
1902 ),
1903 (
1904 "vendor/htmx.min.js",
1905 AdminAsset {
1906 mime: "text/javascript; charset=utf-8",
1907 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
1908 },
1909 ),
1910 (
1911 "fields/ref-picker.js",
1912 AdminAsset {
1913 mime: "text/javascript; charset=utf-8",
1914 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
1915 },
1916 ),
1917 (
1918 "fields/ref-picker.css",
1919 AdminAsset {
1920 mime: "text/css; charset=utf-8",
1921 bytes: std::borrow::Cow::Borrowed(include_bytes!(
1922 "../assets/fields/ref-picker.css"
1923 )),
1924 },
1925 ),
1926 (
1927 "mark.svg",
1928 AdminAsset {
1929 mime: "image/svg+xml",
1930 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
1931 },
1932 ),
1933 (
1934 "mark.png",
1935 AdminAsset {
1936 mime: "image/png",
1937 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
1938 },
1939 ),
1940 (
1941 "fonts/space-grotesk-500.woff2",
1942 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1943 ),
1944 (
1945 "fonts/space-grotesk-600.woff2",
1946 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1947 ),
1948 (
1949 "fonts/space-grotesk-700.woff2",
1950 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1951 ),
1952 (
1953 "fonts/ibm-plex-sans-400.woff2",
1954 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1955 ),
1956 (
1957 "fonts/ibm-plex-sans-600.woff2",
1958 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1959 ),
1960 (
1961 "fonts/ibm-plex-mono-400.woff2",
1962 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1963 ),
1964 (
1965 "fonts/ibm-plex-mono-600.woff2",
1966 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1967 ),
1968 ])
1969}
1970
1971async fn serve_asset(
1973 State(state): State<AdminState>,
1974 headers: HeaderMap,
1975 Path(path): Path<String>,
1976) -> Response {
1977 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
1981 let Some(asset) = state.assets.get(key.as_str()) else {
1982 return not_found();
1983 };
1984 let policy = if fingerprinted {
1985 http_cache::IMMUTABLE
1986 } else {
1987 http_cache::REVALIDATE
1988 };
1989 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
1990}
1991
1992fn mount_resource(
1997 resource: &Resource,
1998 field_types: &field::FieldRegistry,
1999 column_types: &list::ColumnRegistry,
2000 permission_codes: &std::collections::HashSet<String>,
2001 persisters: &persist::PersisterRegistry,
2002 listeners: &[laterite_core::ModelListenerReg],
2003) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
2004 list::check(&resource.list, column_types, permission_codes)
2008 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2009 if resource.form.is_some() && resource.permission.is_none() {
2014 panic!(
2015 "admin resource `{}` has a create/edit form but no permission; a write \
2016 resource must declare a permission",
2017 resource.base_path
2018 );
2019 }
2020 let base = resource.base_path.clone();
2021 let list_path = base.clone();
2022 let mut list_cfg = resource.list.clone();
2023 if resource.list.columns.iter().any(|c| c.editable) {
2026 let Some(form_cfg) = resource.form.clone() else {
2027 panic!(
2028 "admin resource `{}` has an editable column but no form to edit through",
2029 resource.base_path
2030 );
2031 };
2032 for column in resource.list.columns.iter().filter(|c| c.editable) {
2033 if !form_cfg.fields.iter().any(|f| f.name == column.field) {
2034 panic!(
2035 "admin resource `{}`: column `{}` is editable but the form has no such field",
2036 resource.base_path, column.field
2037 );
2038 }
2039 }
2040 let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2041 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2042 list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
2043 }
2044 let cell_cfg = list_cfg.clone();
2045 let mut router = Router::new().route(
2046 &base,
2047 get(
2048 move |State(state): State<AdminState>,
2049 Extension(shell): Extension<Shell>,
2050 Query(params): Query<list::ListParams>,
2051 Query(raw): Query<std::collections::HashMap<String, String>>,
2052 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2053 Extension(user): Extension<AuthenticatedUser>,
2054 Extension(session): Extension<session::SessionHandle>,
2055 headers: axum::http::HeaderMap| {
2056 let cfg = list_cfg.clone();
2057 let path = list_path.clone();
2058 async move {
2059 list::handle_remembering(
2060 &state,
2061 &cfg,
2062 &path,
2063 params,
2064 &raw,
2065 &user,
2066 shell,
2067 &headers,
2068 query_string.as_deref(),
2069 Some(&session),
2070 )
2071 .await
2072 }
2073 },
2074 ),
2075 );
2076
2077 if cell_cfg.editor.is_some() {
2078 let (cfg, list_path) = (cell_cfg, base.clone());
2079 router = router.route(
2080 &format!("{base}/{{id}}/cell"),
2081 post(
2082 move |State(state): State<AdminState>,
2083 Extension(shell): Extension<Shell>,
2084 Extension(user): Extension<AuthenticatedUser>,
2085 Extension(session): Extension<session::SessionHandle>,
2086 Path(id): Path<String>,
2087 headers: axum::http::HeaderMap,
2088 Form(data): Form<HashMap<String, String>>| {
2089 let (cfg, path) = (cfg.clone(), list_path.clone());
2090 async move {
2091 list::cell_handler(
2092 &state, &cfg, &path, &id, &user, &session, shell, &headers, data,
2093 )
2094 .await
2095 }
2096 },
2097 ),
2098 );
2099 }
2100
2101 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2104 if resource.list.exportable {
2105 router = router.route(
2106 &format!("{base}/export"),
2107 get(
2108 move |state: State<AdminState>,
2109 Query(raw): Query<std::collections::HashMap<String, String>>,
2110 Extension(user): Extension<AuthenticatedUser>,
2111 Extension(shell): Extension<Shell>,
2112 Extension(session): Extension<session::SessionHandle>| {
2113 let cfg = export_cfg.clone();
2114 let path = export_path.clone();
2115 async move {
2116 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2117 }
2118 },
2119 ),
2120 );
2121 }
2122
2123 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2124 router = router.route(
2125 &format!("{base}/columns"),
2126 post(
2127 move |State(state): State<AdminState>,
2128 Extension(user): Extension<AuthenticatedUser>,
2129 Extension(session): Extension<session::SessionHandle>,
2130 headers: axum::http::HeaderMap,
2131 Form(pairs): Form<Vec<(String, String)>>| {
2132 let cfg = columns_cfg.clone();
2133 let path = columns_path.clone();
2134 async move {
2135 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2136 }
2137 },
2138 ),
2139 );
2140
2141 if resource.list.deletable {
2142 let persister: Arc<dyn persist::Persister> =
2146 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
2147 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
2148 panic!(
2149 "admin resource `{}`: unregistered persister `{key}`",
2150 resource.base_path
2151 )
2152 }),
2153 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
2154 };
2155 let ctx = Arc::new(bulk::BulkContext {
2156 base_path: resource.base_path.clone(),
2157 entity: resource.list.entity.clone(),
2158 persister,
2159 listeners: listeners
2160 .iter()
2161 .filter(|reg| reg.matches(&resource.list.entity))
2162 .map(|reg| reg.listener.clone())
2163 .collect(),
2164 });
2165 router =
2166 router.route(
2167 &format!("{base}/delete"),
2168 post(
2169 move |state: State<AdminState>,
2170 user: Extension<AuthenticatedUser>,
2171 session: Extension<session::SessionHandle>,
2172 headers: axum::http::HeaderMap,
2173 form: Form<Vec<(String, String)>>| {
2174 let ctx = ctx.clone();
2175 async move {
2176 bulk::delete_handler(state, user, session, ctx, headers, form).await
2177 }
2178 },
2179 ),
2180 );
2181 }
2182
2183 if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
2186 let tree = resource.list.tree.clone().unwrap_or_default();
2187 let mut move_cfg = form::FormConfig::new(
2188 resource.list.entity.clone(),
2189 laterite_core::t!("Move to"),
2190 base.clone(),
2191 resource.list.id_field.clone(),
2192 vec![form::FormField::reference(
2193 &tree.parent_key,
2194 laterite_core::t!("New parent"),
2195 &source,
2196 )
2197 .help(laterite_core::t!("Leave empty for the top level."))],
2198 );
2199 if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
2200 move_cfg = move_cfg.persist(persist);
2201 }
2202 move_cfg.done_path = Some(base.clone());
2203 let move_form = Arc::new(
2204 form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
2205 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
2206 );
2207 let (cfg, list_path) = (resource.list.clone(), base.clone());
2208 let (form_get, form_post) = (move_form.clone(), move_form);
2209 let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
2210 router = router.route(
2211 &format!("{base}/{{id}}/move"),
2212 get(
2213 move |State(state): State<AdminState>,
2214 Extension(shell): Extension<Shell>,
2215 Path(id): Path<String>| {
2216 let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
2217 async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
2218 },
2219 )
2220 .post(
2221 move |State(state): State<AdminState>,
2222 Extension(shell): Extension<Shell>,
2223 Extension(user): Extension<AuthenticatedUser>,
2224 Extension(session): Extension<session::SessionHandle>,
2225 Path(id): Path<String>,
2226 headers: axum::http::HeaderMap,
2227 Form(data): Form<HashMap<String, String>>| {
2228 let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
2229 async move {
2230 list::move_record(
2231 &state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
2232 )
2233 .await
2234 }
2235 },
2236 ),
2237 );
2238 }
2239
2240 if resource.list.reorder.is_some() {
2241 let (cfg, list_path) = (resource.list.clone(), base.clone());
2242 router = router.route(
2243 &format!("{base}/reorder"),
2244 post(
2245 move |State(state): State<AdminState>,
2246 Extension(shell): Extension<Shell>,
2247 Extension(user): Extension<AuthenticatedUser>,
2248 Extension(session): Extension<session::SessionHandle>,
2249 headers: axum::http::HeaderMap,
2250 Form(pairs): Form<Vec<(String, String)>>| {
2251 let cfg = cfg.clone();
2252 let path = list_path.clone();
2253 async move {
2254 list::reorder_handler(
2255 &state, &cfg, &path, "lat-list", None, &user, &session, shell,
2256 &headers, &pairs,
2257 )
2258 .await
2259 }
2260 },
2261 ),
2262 );
2263 }
2264
2265 if !resource.relations.is_empty() && resource.form.is_none() {
2268 panic!(
2269 "admin resource `{}` declares relations but has no form to show them on",
2270 resource.base_path
2271 );
2272 }
2273 let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
2274 for rel in &resource.relations {
2275 let prepared = relation::PreparedRelation::prepare(
2276 resource,
2277 rel.clone(),
2278 field_types,
2279 column_types,
2280 permission_codes,
2281 persisters,
2282 listeners,
2283 )
2284 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2285 relations.push(Arc::new(prepared));
2286 }
2287 let relations = Arc::new(relations);
2288
2289 let mut prepared_form = None;
2290 if let Some(form_cfg) = resource.form.clone() {
2291 let mut prepared =
2294 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2295 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
2296 prepared.stay = !relations.is_empty();
2298 let prepared = Arc::new(prepared);
2299 prepared_form = Some(prepared.clone());
2300 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
2301 let relations_for_new = relations.clone();
2302 router = router.route(
2303 &format!("{base}/new"),
2304 get(
2305 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
2306 let pf = new_pf.clone();
2307 let relations = relations_for_new.clone();
2308 async move {
2309 let pending = relations
2310 .iter()
2311 .map(|r| shell.tt(&r.config.title))
2312 .collect();
2313 form::new_form(&state, &pf, shell, pending)
2314 }
2315 },
2316 )
2317 .post(
2318 move |State(state): State<AdminState>,
2319 Extension(shell): Extension<Shell>,
2320 Extension(user): Extension<AuthenticatedUser>,
2321 Extension(session): Extension<session::SessionHandle>,
2322 headers: axum::http::HeaderMap,
2323 Form(data): Form<HashMap<String, String>>| {
2324 let pf = create_pf.clone();
2325 async move {
2326 form::create(&state, &pf, data, shell, &user, &session, &headers).await
2327 }
2328 },
2329 ),
2330 );
2331
2332 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
2333 let relations_for_edit = relations.clone();
2334 router = router.route(
2335 &format!("{base}/{{id}}/edit"),
2336 get(
2337 move |State(state): State<AdminState>,
2338 Extension(shell): Extension<Shell>,
2339 Extension(user): Extension<AuthenticatedUser>,
2340 Path(id): Path<String>| {
2341 let pf = edit_pf.clone();
2342 let relations = relations_for_edit.clone();
2343 async move {
2344 let mut sections = Vec::new();
2345 for rel in relations.iter() {
2346 if let Some(section) = rel.section(&state, &id, &user, &shell).await {
2347 sections.push(section);
2348 }
2349 }
2350 form::edit_form(&state, &pf, id, shell, sections).await
2351 }
2352 },
2353 )
2354 .post(
2355 move |State(state): State<AdminState>,
2356 Extension(shell): Extension<Shell>,
2357 Extension(user): Extension<AuthenticatedUser>,
2358 Extension(session): Extension<session::SessionHandle>,
2359 Path(id): Path<String>,
2360 headers: axum::http::HeaderMap,
2361 Form(data): Form<HashMap<String, String>>| {
2362 let pf = update_pf.clone();
2363 async move {
2364 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
2365 }
2366 },
2367 ),
2368 );
2369 }
2370
2371 for rel in relations.iter() {
2372 let name = rel.config.name.clone();
2373 let mut routes: Router<AdminState> = Router::new();
2374 let r = rel.clone();
2375 routes =
2376 routes.route(
2377 &format!("{base}/{{id}}/{name}"),
2378 get(
2379 move |state: State<AdminState>,
2380 shell: Extension<Shell>,
2381 user: Extension<AuthenticatedUser>,
2382 id: Path<String>,
2383 params: Query<list::ListParams>,
2384 raw: Query<HashMap<String, String>>,
2385 headers: axum::http::HeaderMap| {
2386 let r = r.clone();
2387 async move {
2388 relation::index(state, shell, user, id, params, raw, headers, r).await
2389 }
2390 },
2391 ),
2392 );
2393 let (r_new, r_create) = (rel.clone(), rel.clone());
2394 routes = routes.route(
2395 &format!("{base}/{{id}}/{name}/new"),
2396 get(
2397 move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
2398 let r = r_new.clone();
2399 async move { relation::new_form(state, shell, id, r).await }
2400 },
2401 )
2402 .post(
2403 move |state: State<AdminState>,
2404 shell: Extension<Shell>,
2405 user: Extension<AuthenticatedUser>,
2406 session: Extension<session::SessionHandle>,
2407 id: Path<String>,
2408 headers: axum::http::HeaderMap,
2409 data: Form<HashMap<String, String>>| {
2410 let r = r_create.clone();
2411 async move {
2412 relation::create(state, shell, user, session, id, headers, data, r).await
2413 }
2414 },
2415 ),
2416 );
2417 let (r_edit, r_update) = (rel.clone(), rel.clone());
2418 routes = routes.route(
2419 &format!("{base}/{{id}}/{name}/{{child}}/edit"),
2420 get(
2421 move |state: State<AdminState>,
2422 shell: Extension<Shell>,
2423 ids: Path<(String, String)>| {
2424 let r = r_edit.clone();
2425 async move { relation::edit_form(state, shell, ids, r).await }
2426 },
2427 )
2428 .post(
2429 move |state: State<AdminState>,
2430 shell: Extension<Shell>,
2431 user: Extension<AuthenticatedUser>,
2432 session: Extension<session::SessionHandle>,
2433 ids: Path<(String, String)>,
2434 headers: axum::http::HeaderMap,
2435 data: Form<HashMap<String, String>>| {
2436 let r = r_update.clone();
2437 async move {
2438 relation::update(state, shell, user, session, ids, headers, data, r).await
2439 }
2440 },
2441 ),
2442 );
2443 let r_delete = rel.clone();
2444 routes = routes.route(
2445 &format!("{base}/{{id}}/{name}/delete"),
2446 post(
2447 move |state: State<AdminState>,
2448 shell: Extension<Shell>,
2449 user: Extension<AuthenticatedUser>,
2450 session: Extension<session::SessionHandle>,
2451 id: Path<String>,
2452 headers: axum::http::HeaderMap,
2453 pairs: Form<Vec<(String, String)>>| {
2454 let r = r_delete.clone();
2455 async move {
2456 relation::delete(state, shell, user, session, id, headers, pairs, r).await
2457 }
2458 },
2459 ),
2460 );
2461 if rel.reorderable() {
2462 let r_reorder = rel.clone();
2463 routes = routes.route(
2464 &format!("{base}/{{id}}/{name}/reorder"),
2465 post(
2466 move |state: State<AdminState>,
2467 shell: Extension<Shell>,
2468 user: Extension<AuthenticatedUser>,
2469 session: Extension<session::SessionHandle>,
2470 id: Path<String>,
2471 headers: axum::http::HeaderMap,
2472 pairs: Form<Vec<(String, String)>>| {
2473 let r = r_reorder.clone();
2474 async move {
2475 relation::reorder(state, shell, user, session, id, headers, pairs, r)
2476 .await
2477 }
2478 },
2479 ),
2480 );
2481 }
2482 if let Some(permission) = &rel.config.permission {
2483 routes = guard_with_permission(routes, permission);
2484 }
2485 router = router.merge(routes);
2486 }
2487
2488 if let Some(permission) = &resource.permission {
2490 router = guard_with_permission(router, permission);
2491 }
2492 (router, prepared_form)
2493}
2494
2495fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
2500 let needed: Arc<str> = Arc::from(permission);
2501 router.route_layer(middleware::from_fn(
2502 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
2503 let needed = needed.clone();
2504 async move {
2505 if user.allows(&needed) {
2506 next.run(req).await
2507 } else {
2508 forbidden()
2509 }
2510 }
2511 },
2512 ))
2513}
2514
2515async fn require_auth(
2521 State(state): State<AdminState>,
2522 jar: CookieJar,
2523 request: Request,
2524 next: Next,
2525) -> Response {
2526 let login = format!("{}/login", state.admin_path);
2527 let client_ctx = request
2528 .extensions()
2529 .get::<RequestContext>()
2530 .cloned()
2531 .unwrap_or_default();
2532 let mut ended: Option<laterite_auth::SessionEnd> = None;
2537 let live = match jar.get(SESSION_COOKIE) {
2538 Some(cookie) => {
2539 let token = cookie.value().to_string();
2540 match state.auth.resolve_session(&token).await {
2541 Ok(resolved) => Some((token, resolved)),
2542 Err(_) => {
2543 ended = state.auth.session_end(&token).await.ok().flatten();
2544 None
2545 }
2546 }
2547 }
2548 None => None,
2549 };
2550 let htmx = request.headers().contains_key("hx-request");
2551 let (token, resolved, recalled) = match live {
2552 Some((token, resolved)) => (token, resolved, None),
2553 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2556 return signed_out(&state, jar, &login, ended, htmx);
2557 }
2558 None => match recall(&state, &jar, &client_ctx).await {
2559 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2560 None => {
2561 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2565 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2566 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2567 }
2568 }
2569 return signed_out(&state, jar, &login, ended, htmx);
2570 }
2571 },
2572 };
2573 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2574
2575 let mut request = request;
2580 let safe = session::is_safe_method(request.method());
2581 let mut deferred: Option<session::CsrfPending> = None;
2583 if !safe {
2584 let (parts, body) = request.into_parts();
2585 let (body, submitted) = if session::is_multipart(&parts.headers) {
2592 let token = session::header_token(&parts.headers)
2593 .or_else(|| session::query_token(parts.uri.query()));
2594 if token.is_none() {
2599 deferred = Some(session::CsrfPending::default());
2600 }
2601 (body, token)
2602 } else {
2603 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2604 .await
2605 .unwrap_or_default();
2606 let token = session::submitted_token(&parts.headers, &bytes);
2607 (Body::from(bytes), token)
2608 };
2609 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2610 {
2611 tracing::warn!(
2612 user_id = resolved.identity.user.id,
2613 "admin CSRF check failed"
2614 );
2615 return error::csrf_rejected();
2616 }
2617 request = Request::from_parts(parts, body);
2618 }
2619
2620 let user = resolved.identity;
2621 let acting_user_id = user.user.id;
2623 let path = request.uri().path().to_string();
2624 let preferences = format!("{}/preferences", state.admin_path);
2627 if !path.starts_with(&preferences)
2628 && path != format!("{}/logout", state.admin_path)
2629 && state
2630 .auth
2631 .must_change_password(user.user.id)
2632 .await
2633 .unwrap_or(false)
2634 {
2635 let to = format!("{preferences}#password");
2636 let response = if htmx {
2637 (
2638 [("HX-Redirect", to.as_str())],
2639 axum::http::StatusCode::NO_CONTENT,
2640 )
2641 .into_response()
2642 } else {
2643 Redirect::to(&to).into_response()
2644 };
2645 return with_recall_cookies(response, recalled, jar, token, &state);
2646 }
2647 let flash = if safe {
2650 handle.take_flash()
2651 } else {
2652 Vec::new()
2653 };
2654 let accept_language = request
2658 .headers()
2659 .get("accept-language")
2660 .and_then(|v| v.to_str().ok());
2661 let serveable = state.catalogs.locales();
2662 let chain = resolve_locale_chain(
2663 user.user.locale.as_deref(),
2664 accept_language,
2665 &state.default_locale,
2666 &serveable,
2667 );
2668 let i18n = Translator::with_chain(chain, state.catalogs.clone());
2669 let translator = i18n.clone();
2672 let (sidebar, active_nav) = resolve_nav_context(
2673 &state.nav,
2674 &state.settings,
2675 &state.admin_path,
2676 &user.permissions,
2677 &path,
2678 &i18n,
2679 icons::Icons::new(&state.icons, &state.sprite_url),
2680 );
2681 let brand = state.brand().await;
2682 let shell = Shell::new(
2683 &state.admin_path,
2684 brand,
2685 &state.nav,
2686 &user,
2687 state.timezone,
2688 sidebar,
2689 active_nav.as_deref(),
2690 handle.csrf_token(),
2691 flash,
2692 i18n,
2693 state.asset_urls.clone(),
2694 state.origin.to_string(),
2695 state.icons.clone(),
2696 state.sprite_url.clone(),
2697 );
2698 request.extensions_mut().insert(user);
2699 request.extensions_mut().insert(shell);
2700 request.extensions_mut().insert(handle.clone());
2701 if let Some(pending) = &deferred {
2703 request.extensions_mut().insert(pending.clone());
2704 }
2705 let mut response = next.run(request).await;
2706
2707 if let Some(pending) = &deferred {
2712 if !pending.was_satisfied() {
2713 tracing::error!(
2714 user_id = acting_user_id,
2715 "a multipart route returned without verifying its request token"
2716 );
2717 return error::csrf_rejected();
2718 }
2719 }
2720
2721 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2725 response = error::localized_error(meta.kind, &translator);
2726 }
2727
2728 if let Some(blob) = handle.dirty_blob() {
2731 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2732 tracing::error!(error = %e, "persisting admin session failed");
2733 }
2734 }
2735 with_recall_cookies(response, recalled, jar, token, &state)
2736}
2737
2738fn with_recall_cookies(
2742 response: Response,
2743 recalled: Option<laterite_auth::RememberCredential>,
2744 jar: CookieJar,
2745 token: String,
2746 state: &AdminState,
2747) -> Response {
2748 match recalled {
2749 Some(credential) => {
2750 let jar = jar
2751 .add(session_cookie(
2752 token,
2753 &state.admin_path,
2754 state.secure_cookie,
2755 ))
2756 .add(remember_cookie(
2757 credential,
2758 &state.admin_path,
2759 state.secure_cookie,
2760 ));
2761 (jar, response).into_response()
2762 }
2763 None => response,
2764 }
2765}
2766
2767async fn recall(
2771 state: &AdminState,
2772 jar: &CookieJar,
2773 ctx: &RequestContext,
2774) -> Option<(
2775 String,
2776 laterite_auth::ResolvedSession,
2777 laterite_auth::RememberCredential,
2778)> {
2779 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
2780 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
2781 let token = recalled.session.token.clone();
2782 let resolved = state.auth.resolve_session(&token).await.ok()?;
2783 Some((token, resolved, recalled.remember))
2784}
2785
2786const MAX_FORM_BYTES: usize = 1024 * 1024;
2789
2790async fn capture_client(
2798 State(state): State<AdminState>,
2799 mut request: Request,
2800 next: Next,
2801) -> Response {
2802 let peer = request
2803 .extensions()
2804 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
2805 .map(|info| info.0);
2806 let headers = request.headers();
2807 let ctx = RequestContext {
2808 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
2809 user_agent: clientip::user_agent(headers),
2810 };
2811 request.extensions_mut().insert(ctx);
2812 next.run(request).await
2813}
2814
2815async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
2816 if !session::is_safe_method(request.method())
2817 && !session::origin_ok(request.headers(), &state.origin)
2818 {
2819 tracing::warn!(
2820 path = request.uri().path(),
2821 "admin CSRF origin check failed"
2822 );
2823 return error::csrf_rejected();
2824 }
2825 next.run(request).await
2826}
2827
2828#[derive(Deserialize)]
2829struct LoginQuery {
2830 ended: Option<String>,
2831}
2832
2833async fn login_form(
2834 State(state): State<AdminState>,
2835 headers: HeaderMap,
2836 Query(query): Query<LoginQuery>,
2837) -> Response {
2838 match state.auth.has_any_operator().await {
2840 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
2841 Ok(true) => {
2842 let i18n = pre_auth_translator(&state, &headers);
2843 let notice = query
2844 .ended
2845 .as_deref()
2846 .and_then(end_notice)
2847 .map(|text| i18n.t(&text));
2848 render(LoginTemplate {
2849 base: state.admin_path.to_string(),
2850 asset_urls: state.asset_urls.clone(),
2851 brand: state.brand().await,
2852 error: None,
2853 notice,
2854 i18n,
2855 })
2856 }
2857 Err(_) => render_error(),
2858 }
2859}
2860
2861#[derive(Deserialize)]
2862struct LoginForm {
2863 username: String,
2864 password: String,
2865 #[serde(default)]
2867 remember: Option<String>,
2868}
2869
2870async fn login_submit(
2871 State(state): State<AdminState>,
2872 jar: CookieJar,
2873 headers: HeaderMap,
2874 Extension(ctx): Extension<RequestContext>,
2875 Form(form): Form<LoginForm>,
2876) -> Response {
2877 match state
2878 .auth
2879 .authenticate(&form.username, &form.password, &ctx)
2880 .await
2881 {
2882 Ok(session) => {
2883 let wants_remember = form.remember.is_some();
2884 let user_id = session.user_id;
2885 let mut jar = jar.add(session_cookie(
2886 session.token,
2887 &state.admin_path,
2888 state.secure_cookie,
2889 ));
2890 if wants_remember {
2891 match state.auth.issue_remember(user_id).await {
2892 Ok(credential) => {
2893 jar = jar.add(remember_cookie(
2894 credential,
2895 &state.admin_path,
2896 state.secure_cookie,
2897 ));
2898 }
2899 Err(e) => {
2901 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
2902 }
2903 }
2904 }
2905 (jar, Redirect::to(&state.admin_path)).into_response()
2906 }
2907 Err(_) => {
2908 let i18n = pre_auth_translator(&state, &headers);
2909 render(LoginTemplate {
2910 base: state.admin_path.to_string(),
2911 asset_urls: state.asset_urls.clone(),
2912 brand: state.brand().await,
2913 error: Some(i18n.t(&t!("Invalid username or password."))),
2914 notice: None,
2915 i18n,
2916 })
2917 }
2918 }
2919}
2920
2921fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
2930 Cookie::build((SESSION_COOKIE, token))
2931 .path(admin_path.to_string())
2932 .http_only(true)
2933 .secure(secure)
2934 .same_site(SameSite::Lax)
2935 .build()
2936}
2937
2938fn remember_cookie(
2941 credential: laterite_auth::RememberCredential,
2942 admin_path: &str,
2943 secure: bool,
2944) -> Cookie<'static> {
2945 let seconds = (credential.expires_at - chrono::Utc::now())
2946 .num_seconds()
2947 .max(0);
2948 Cookie::build((REMEMBER_COOKIE, credential.cookie))
2949 .path(admin_path.to_string())
2950 .http_only(true)
2951 .secure(secure)
2952 .same_site(SameSite::Lax)
2953 .max_age(cookie::time::Duration::seconds(seconds))
2954 .build()
2955}
2956
2957fn signed_out(
2964 state: &AdminState,
2965 jar: CookieJar,
2966 login: &str,
2967 ended: Option<laterite_auth::SessionEnd>,
2968 htmx: bool,
2969) -> Response {
2970 let to = match ended.map(laterite_auth::SessionEnd::code) {
2971 Some(code) => format!("{login}?ended={code}"),
2972 None => login.to_string(),
2973 };
2974 let jar = jar
2975 .remove(
2976 Cookie::build((SESSION_COOKIE, ""))
2977 .path(state.admin_path.to_string())
2978 .build(),
2979 )
2980 .remove(remember_removal(&state.admin_path));
2981 if htmx {
2982 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
2983 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
2984 response.headers_mut().insert("hx-redirect", value);
2985 }
2986 return response;
2987 }
2988 (jar, Redirect::to(&to)).into_response()
2989}
2990
2991fn end_notice(code: &str) -> Option<Text> {
2994 Some(match code {
2995 "password_changed" => {
2996 t!("Your password was changed, so you were signed out. Sign in with the new one.")
2997 }
2998 "deactivated" => t!("This account was deactivated."),
2999 "signed_out_elsewhere" => t!("You were signed out from another device."),
3000 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
3001 _ => return None,
3002 })
3003}
3004
3005fn remember_removal(admin_path: &str) -> Cookie<'static> {
3007 Cookie::build((REMEMBER_COOKIE, ""))
3008 .path(admin_path.to_string())
3009 .build()
3010}
3011
3012#[derive(Deserialize)]
3013struct SetupForm {
3014 username: String,
3015 first_name: String,
3016 last_name: String,
3017 email: String,
3018 password: String,
3019 timezone: String,
3020}
3021
3022async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
3025 match state.auth.has_any_operator().await {
3026 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3027 Ok(false) => render(setup_view(
3028 &state.admin_path,
3029 state.brand().await,
3030 state.timezone,
3031 None,
3032 pre_auth_translator(&state, &headers),
3033 state.asset_urls.clone(),
3034 state.auth.password_policy().min_length,
3035 )),
3036 Err(_) => render_error(),
3037 }
3038}
3039
3040async fn setup_submit(
3041 State(state): State<AdminState>,
3042 jar: CookieJar,
3043 headers: HeaderMap,
3044 Form(form): Form<SetupForm>,
3045) -> Response {
3046 match state.auth.has_any_operator().await {
3048 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3049 Ok(false) => {}
3050 Err(_) => return render_error(),
3051 }
3052
3053 let username = form.username.trim();
3054 let email = form.email.trim();
3055 let first_name = form.first_name.trim();
3056 let last_name = form.last_name.trim();
3057 let tz = form.timezone.trim();
3058 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
3059 {
3060 return render(setup_view(
3061 &state.admin_path,
3062 state.brand().await,
3063 state.timezone,
3064 Some(t!(
3065 "Username, first name, email, and password are all required."
3066 )),
3067 pre_auth_translator(&state, &headers),
3068 state.asset_urls.clone(),
3069 state.auth.password_policy().min_length,
3070 ));
3071 }
3072 if tz.parse::<Tz>().is_err() {
3074 return render(setup_view(
3075 &state.admin_path,
3076 state.brand().await,
3077 state.timezone,
3078 Some(t!("That is not a recognised timezone.")),
3079 pre_auth_translator(&state, &headers),
3080 state.asset_urls.clone(),
3081 state.auth.password_policy().min_length,
3082 ));
3083 }
3084
3085 let min_length = state.auth.password_policy().min_length;
3086 if form.password.chars().count() < min_length {
3087 return render(setup_view(
3088 &state.admin_path,
3089 state.brand().await,
3090 state.timezone,
3091 Some(t!(
3092 "A password needs at least {n} characters.",
3093 n = min_length as i64
3094 )),
3095 pre_auth_translator(&state, &headers),
3096 state.asset_urls.clone(),
3097 state.auth.password_policy().min_length,
3098 ));
3099 }
3100 let new = NewOperator {
3101 username,
3102 email,
3103 first_name,
3104 last_name: (!last_name.is_empty()).then_some(last_name),
3105 password: &form.password,
3106 timezone: Some(tz),
3107 };
3108 let id = match state.auth.create_superuser(new).await {
3109 Ok(id) => id,
3110 Err(_) => {
3111 return render(setup_view(
3112 &state.admin_path,
3113 state.brand().await,
3114 state.timezone,
3115 Some(t!(
3116 "Could not create the account. The username or email may already be taken."
3117 )),
3118 pre_auth_translator(&state, &headers),
3119 state.asset_urls.clone(),
3120 state.auth.password_policy().min_length,
3121 ));
3122 }
3123 };
3124 let actor = laterite_core::Actor::system("first-run setup");
3127 let target = id.to_string();
3128 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
3129 "backend_user",
3130 &target,
3131 Some(username),
3132 );
3133 if let Err(e) = state.auth.record_audit(entry).await {
3134 tracing::error!(error = %e, "failed to write audit log entry");
3135 }
3136
3137 match state
3139 .auth
3140 .authenticate(username, &form.password, &RequestContext::default())
3141 .await
3142 {
3143 Ok(session) => {
3144 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
3145 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
3146 }
3147 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
3148 }
3149}
3150
3151fn setup_view(
3154 admin_path: &str,
3155 brand: String,
3156 default_tz: Tz,
3157 error: Option<Text>,
3158 i18n: Translator,
3159 asset_urls: Arc<AssetUrls>,
3160 min_password: usize,
3161) -> SetupTemplate {
3162 let default_name = default_tz.name();
3163 let zones = TZ_VARIANTS
3164 .iter()
3165 .filter(|tz| zone_offered(tz.name()))
3166 .map(|tz| TzOption {
3167 name: tz.name().to_string(),
3168 selected: tz.name() == default_name,
3169 })
3170 .collect();
3171 SetupTemplate {
3172 base: admin_path.to_string(),
3173 asset_urls,
3174 brand,
3175 zones,
3176 error: error.map(|e| i18n.t(&e)),
3177 min_password: min_password as i64,
3178 i18n,
3179 }
3180}
3181
3182async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
3183 if let Some(cookie) = jar.get(SESSION_COOKIE) {
3184 let _ = state.auth.logout(cookie.value()).await;
3185 }
3186 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
3189 let _ = state.auth.revoke_remember(cookie.value()).await;
3190 }
3191 let removal = Cookie::build((SESSION_COOKIE, ""))
3192 .path(state.admin_path.to_string())
3193 .build();
3194 (
3195 jar.remove(removal)
3196 .remove(remember_removal(&state.admin_path)),
3197 Redirect::to(&format!("{}/login", state.admin_path)),
3198 )
3199 .into_response()
3200}
3201
3202async fn dashboard(
3203 Extension(shell): Extension<Shell>,
3204 Extension(user): Extension<AuthenticatedUser>,
3205) -> Response {
3206 render(DashboardTemplate {
3207 username: user.user.username,
3208 shell,
3209 })
3210}
3211
3212fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
3215 match &item.permission {
3216 None => true,
3217 Some(p) => perms.allows(p),
3218 }
3219}
3220
3221fn visible_settings(
3225 items: &[settings::SettingsItem],
3226 perms: &PermissionSet,
3227) -> Vec<settings::SettingsItem> {
3228 items
3229 .iter()
3230 .filter(|item| operator_can_see(item, perms))
3231 .cloned()
3232 .collect()
3233}
3234
3235async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
3236 settings::index(shell)
3237}
3238
3239async fn settings_edit(
3240 state: AdminState,
3241 shell: Shell,
3242 user: AuthenticatedUser,
3243 code: String,
3244) -> Response {
3245 let items = visible_settings(&state.settings, &user.permissions);
3248 match items
3249 .iter()
3250 .find(|item| item.code == code && item.link.is_none())
3251 {
3252 Some(item) => settings::edit_form(&state, item, shell).await,
3253 None => not_found(),
3254 }
3255}
3256
3257async fn settings_update(
3258 state: AdminState,
3259 shell: Shell,
3260 user: AuthenticatedUser,
3261 session: session::SessionHandle,
3262 code: String,
3263 data: HashMap<String, String>,
3264) -> Response {
3265 let items = visible_settings(&state.settings, &user.permissions);
3266 match items
3267 .iter()
3268 .find(|item| item.code == code && item.link.is_none())
3269 {
3270 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
3271 None => not_found(),
3272 }
3273}
3274
3275async fn preferences_form(
3277 State(state): State<AdminState>,
3278 Extension(shell): Extension<Shell>,
3279 Extension(user): Extension<AuthenticatedUser>,
3280 jar: CookieJar,
3281) -> Response {
3282 let account = account_security(&state, &user, &shell, &jar).await;
3283 render(preferences_view(
3284 &shell,
3285 &user,
3286 state.timezone,
3287 &state.default_locale,
3288 &offered_locales(&state.catalogs),
3289 account,
3290 state.auth.password_policy().min_length,
3291 ))
3292}
3293
3294#[derive(Deserialize)]
3295struct RevokeSessionForm {
3296 id: String,
3297}
3298
3299async fn session_revoke(
3301 State(state): State<AdminState>,
3302 Extension(user): Extension<AuthenticatedUser>,
3303 Extension(session): Extension<session::SessionHandle>,
3304 Form(form): Form<RevokeSessionForm>,
3305) -> Response {
3306 match state.auth.revoke_session(user.user.id, &form.id).await {
3307 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
3308 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
3309 Err(e) => {
3310 tracing::error!(error = %e, "revoking a session failed");
3311 session.push_flash(
3312 FlashLevel::Error,
3313 t!("That session could not be signed out."),
3314 );
3315 }
3316 }
3317 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3318}
3319
3320async fn session_revoke_others(
3322 State(state): State<AdminState>,
3323 Extension(user): Extension<AuthenticatedUser>,
3324 Extension(session): Extension<session::SessionHandle>,
3325 jar: CookieJar,
3326) -> Response {
3327 let token = jar
3328 .get(SESSION_COOKIE)
3329 .map(|c| c.value().to_string())
3330 .unwrap_or_default();
3331 match state.auth.sign_out_everywhere(user.user.id, &token).await {
3332 Ok(_) => session.push_flash(
3333 FlashLevel::Success,
3334 t!("Every other session was signed out."),
3335 ),
3336 Err(e) => {
3337 tracing::error!(error = %e, "signing out other sessions failed");
3338 session.push_flash(
3339 FlashLevel::Error,
3340 t!("Those sessions could not be signed out."),
3341 );
3342 }
3343 }
3344 (
3347 jar.remove(remember_removal(&state.admin_path)),
3348 Redirect::to(&format!("{}/preferences", state.admin_path)),
3349 )
3350 .into_response()
3351}
3352
3353#[derive(Deserialize)]
3354struct PasswordForm {
3355 current_password: String,
3356 new_password: String,
3357 confirm_password: String,
3358}
3359
3360async fn password_update(
3365 State(state): State<AdminState>,
3366 Extension(user): Extension<AuthenticatedUser>,
3367 Extension(session): Extension<session::SessionHandle>,
3368 Extension(ctx): Extension<RequestContext>,
3369 jar: CookieJar,
3370 Form(form): Form<PasswordForm>,
3371) -> Response {
3372 let back = format!("{}/preferences#password", state.admin_path);
3373 let refuse = |message: Text| {
3374 session.push_flash(FlashLevel::Error, message);
3375 Redirect::to(&back).into_response()
3376 };
3377 if form.new_password != form.confirm_password {
3378 return refuse(t!("The new passwords do not match."));
3379 }
3380 let min_length = state.auth.password_policy().min_length;
3381 if form.new_password.chars().count() < min_length {
3382 return refuse(t!(
3383 "A password needs at least {n} characters.",
3384 n = min_length as i64
3385 ));
3386 }
3387 let remembered_here = match jar.get(REMEMBER_COOKIE) {
3390 Some(cookie) => {
3391 state
3392 .auth
3393 .remember_holder(cookie.value())
3394 .await
3395 .ok()
3396 .flatten()
3397 == Some(user.user.id)
3398 }
3399 None => false,
3400 };
3401 let token = jar
3402 .get(SESSION_COOKIE)
3403 .map(|c| c.value().to_string())
3404 .unwrap_or_default();
3405 let changed = state
3406 .auth
3407 .change_own_password(
3408 user.user.id,
3409 &form.current_password,
3410 &form.new_password,
3411 &token,
3412 &ctx,
3413 )
3414 .await;
3415 match changed {
3416 Ok(()) => {}
3417 Err(laterite_auth::AuthError::InvalidCredentials) => {
3418 return refuse(t!("Your current password is not correct."));
3419 }
3420 Err(laterite_auth::AuthError::TooManyAttempts) => {
3421 return refuse(t!("Too many attempts. Try again later."));
3422 }
3423 Err(e) => {
3424 tracing::error!(error = %e, "changing a password failed");
3425 return refuse(t!("The password could not be changed."));
3426 }
3427 }
3428 session.push_flash_sticky(
3429 FlashLevel::Success,
3430 t!("Password changed. Every other device was signed out."),
3431 );
3432 let jar = if remembered_here {
3433 match state.auth.issue_remember(user.user.id).await {
3434 Ok(credential) => jar.add(remember_cookie(
3435 credential,
3436 &state.admin_path,
3437 state.secure_cookie,
3438 )),
3439 Err(e) => {
3440 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
3441 jar.remove(remember_removal(&state.admin_path))
3442 }
3443 }
3444 } else if jar.get(REMEMBER_COOKIE).is_some() {
3445 jar.remove(remember_removal(&state.admin_path))
3448 } else {
3449 jar
3450 };
3451 (
3452 jar,
3453 Redirect::to(&format!("{}/preferences", state.admin_path)),
3454 )
3455 .into_response()
3456}
3457
3458#[derive(Deserialize)]
3459struct PreferencesForm {
3460 timezone: String,
3461 #[serde(default)]
3463 locale: String,
3464}
3465
3466async fn preferences_update(
3467 State(state): State<AdminState>,
3468 Extension(user): Extension<AuthenticatedUser>,
3469 Extension(session): Extension<session::SessionHandle>,
3470 Form(form): Form<PreferencesForm>,
3471) -> Response {
3472 let back = format!("{}/preferences", state.admin_path);
3473 let offered = offered_locales(&state.catalogs);
3474 let tz = form.timezone.trim();
3476 let tz_stored = if tz.is_empty() {
3477 None
3478 } else if tz.parse::<Tz>().is_ok() {
3479 Some(tz)
3480 } else {
3481 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
3482 return Redirect::to(&back).into_response();
3483 };
3484 let loc = form.locale.trim();
3485 let loc_stored = if loc.is_empty() {
3486 None
3487 } else if offered.iter().any(|l| l == loc) {
3488 Some(loc)
3489 } else {
3490 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
3491 return Redirect::to(&back).into_response();
3492 };
3493 if state
3494 .auth
3495 .set_user_timezone(user.user.id, tz_stored)
3496 .await
3497 .is_err()
3498 {
3499 return render_error();
3500 }
3501 match state.auth.set_user_locale(user.user.id, loc_stored).await {
3502 Ok(()) => {
3503 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
3504 Redirect::to(&back).into_response()
3505 }
3506 Err(_) => render_error(),
3507 }
3508}
3509
3510fn preferences_view(
3514 shell: &Shell,
3515 user: &AuthenticatedUser,
3516 default_tz: Tz,
3517 default_locale: &str,
3518 offered: &[String],
3519 account: AccountSecurity,
3520 min_password: usize,
3521) -> PreferencesTemplate {
3522 let current = user.user.timezone.as_deref();
3523 let zones = TZ_VARIANTS
3524 .iter()
3525 .filter(|tz| zone_offered(tz.name()))
3526 .map(|tz| TzOption {
3527 name: tz.name().to_string(),
3528 selected: current == Some(tz.name()),
3529 })
3530 .collect();
3531 let current_locale = user.user.locale.as_deref();
3532 let locales = offered
3533 .iter()
3534 .map(|code| LocaleOption {
3535 name: locale_name(code),
3536 selected: current_locale == Some(code.as_str()),
3537 code: code.clone(),
3538 })
3539 .collect();
3540 PreferencesTemplate {
3541 shell: shell.clone(),
3542 zones,
3543 effective_tz: shell.tz.name().to_string(),
3544 default_tz: default_tz.name().to_string(),
3545 inherits: current.is_none(),
3546 locales,
3547 default_locale: locale_name(default_locale),
3548 inherits_locale: current_locale.is_none(),
3549 sessions: account.sessions,
3550 password_changed: account.password_changed,
3551 must_change: account.must_change,
3552 min_password: min_password as i64,
3553 }
3554}
3555
3556struct AccountSecurity {
3559 sessions: Vec<SessionRow>,
3560 password_changed: Option<String>,
3561 must_change: bool,
3563}
3564
3565async fn account_security(
3566 state: &AdminState,
3567 user: &AuthenticatedUser,
3568 shell: &Shell,
3569 jar: &CookieJar,
3570) -> AccountSecurity {
3571 let locale = list::date_locale(shell.locale());
3572 let password_changed = match state.auth.password_changed_at(user.user.id).await {
3573 Ok(at) => {
3574 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
3575 }
3576 Err(e) => {
3577 tracing::error!(error = %e, "reading the password change time failed");
3578 None
3579 }
3580 };
3581 AccountSecurity {
3582 sessions: session_rows(state, user, shell, jar).await,
3583 password_changed,
3584 must_change: state
3585 .auth
3586 .must_change_password(user.user.id)
3587 .await
3588 .unwrap_or(false),
3589 }
3590}
3591
3592async fn session_rows(
3595 state: &AdminState,
3596 user: &AuthenticatedUser,
3597 shell: &Shell,
3598 jar: &CookieJar,
3599) -> Vec<SessionRow> {
3600 let token = jar
3601 .get(SESSION_COOKIE)
3602 .map(|c| c.value().to_string())
3603 .unwrap_or_default();
3604 let locale = list::date_locale(shell.locale());
3605 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
3606 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
3607 };
3608 match state.auth.list_sessions(user.user.id, &token).await {
3609 Ok(sessions) => sessions
3610 .into_iter()
3611 .map(|s| SessionRow {
3612 id: s.id,
3613 started: stamp(s.created_at),
3614 last_seen: stamp(s.last_seen_at),
3615 expires: stamp(s.expires_at),
3616 device: clientip::describe(s.user_agent.as_deref())
3617 .or(s.user_agent)
3618 .unwrap_or_else(|| "-".to_string()),
3619 ip_address: s.ip_address.unwrap_or_default(),
3620 current: s.current,
3621 })
3622 .collect(),
3623 Err(e) => {
3624 tracing::error!(error = %e, "listing sessions failed");
3625 Vec::new()
3626 }
3627 }
3628}
3629
3630fn builtin_resources() -> Vec<Resource> {
3632 vec![
3633 Resource {
3634 base_path: "/users".to_string(),
3635 nav_label: "Backend Users".into(),
3636 list: backend_users_list_config(),
3637 form: None,
3638 permission: Some("backend.manage_users".to_string()),
3639 import: None,
3640 relations: Vec::new(),
3641 },
3642 Resource {
3643 base_path: "/roles".to_string(),
3644 nav_label: "Roles".into(),
3645 list: roles_list_config(),
3646 form: None,
3649 permission: Some("backend.manage_roles".to_string()),
3650 import: None,
3651 relations: Vec::new(),
3652 },
3653 resource!("descriptors/audit-log.yaml"),
3654 resource!("descriptors/access-log.yaml"),
3655 ]
3656}
3657
3658fn builtin_settings() -> Vec<settings::SettingsItem> {
3662 vec![
3663 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
3664 .description("Manage backend administrator accounts.")
3665 .category("Users")
3666 .order(10)
3667 .icon("users")
3668 .permission("backend.manage_users")
3669 .link("/users"),
3670 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
3671 .description("Manage roles and their permissions.")
3672 .category("Users")
3673 .order(20)
3674 .icon("shield")
3675 .permission("backend.manage_roles")
3676 .link("/roles"),
3677 settings::brand::settings_item(),
3678 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
3679 .description("Enable or disable installed plugins.")
3680 .category("System")
3681 .order(10)
3682 .icon("plug")
3683 .permission(plugins::MANAGE_PERMISSION)
3684 .link("/plugins"),
3685 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
3686 .description("Review the record of administrative changes.")
3687 .category("System")
3688 .order(20)
3689 .icon("history")
3690 .permission("backend.view_audit_log")
3691 .link("/audit-log"),
3692 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
3693 .description("Review sign-ins, failures and lockouts.")
3694 .category("System")
3695 .order(30)
3696 .icon("log-in")
3697 .permission("backend.view_access_log")
3698 .link("/access-log"),
3699 ]
3700}
3701
3702fn backend_users_list_config() -> list::ListConfig {
3703 list::ListConfig {
3704 entity: "backend_users".to_string(),
3705 title: "Backend Users".into(),
3706 columns: vec![
3707 list::ListColumn::new("username", "Username"),
3708 list::ListColumn::new("email", "Email"),
3709 list::ListColumn::new("first_name", "First name"),
3710 list::ListColumn::new("last_name", "Last name"),
3711 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
3712 list::ListColumn::new("is_active", "Active").yes_no(),
3713 list::ListColumn::new("created_at", "Created").datetime(),
3714 ],
3715 order_by: "created_at".to_string(),
3716 creatable: true,
3719 edit_base: Some("/users".to_string()),
3720 filters: vec![
3721 list::ListFilter::boolean("is_active", "Active"),
3722 list::ListFilter::boolean("is_superuser", "Superuser"),
3723 ],
3724 ..Default::default()
3728 }
3729}
3730
3731fn roles_list_config() -> list::ListConfig {
3732 list::ListConfig {
3733 entity: "backend_roles".to_string(),
3734 title: "Roles".into(),
3735 columns: vec![
3736 list::ListColumn::new("code", "Code"),
3737 list::ListColumn::new("name", "Name"),
3738 list::ListColumn::new("created_at", "Created").datetime(),
3739 ],
3740 order_by: "created_at".to_string(),
3741 edit_base: Some("/roles".to_string()),
3742 creatable: true,
3743 deletable: true,
3744 ..Default::default()
3745 }
3746}
3747
3748#[derive(Template)]
3749#[template(path = "login.html")]
3750struct LoginTemplate {
3751 base: String,
3753 asset_urls: Arc<AssetUrls>,
3755 brand: String,
3756 error: Option<String>,
3757 notice: Option<String>,
3759 i18n: Translator,
3762}
3763
3764fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
3767 match urls.get(key) {
3768 Some(path) => format!("{base}/assets/{path}"),
3769 None => {
3770 debug_assert!(false, "asset key `{key}` is not registered");
3771 format!("{base}/assets/{key}")
3772 }
3773 }
3774}
3775
3776impl LoginTemplate {
3777 fn asset(&self, key: &str) -> String {
3778 preauth_asset(&self.asset_urls, &self.base, key)
3779 }
3780 fn t(&self, source: &str) -> String {
3781 self.i18n.t(&Text::dynamic(source))
3782 }
3783 fn locale(&self) -> &str {
3784 self.i18n.locale()
3785 }
3786}
3787
3788#[derive(Template)]
3789#[template(path = "dashboard.html")]
3790struct DashboardTemplate {
3791 shell: Shell,
3792 username: String,
3793}
3794
3795#[derive(Template)]
3796#[template(path = "setup.html")]
3797struct SetupTemplate {
3798 base: String,
3800 asset_urls: Arc<AssetUrls>,
3802 brand: String,
3803 zones: Vec<TzOption>,
3804 error: Option<String>,
3805 i18n: Translator,
3807 min_password: i64,
3809}
3810
3811impl SetupTemplate {
3812 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
3815 let mut text = Text::dynamic(source);
3816 for (name, value) in args {
3817 text = text.arg(*name, *value);
3818 }
3819 self.i18n.t(&text)
3820 }
3821
3822 fn asset(&self, key: &str) -> String {
3823 preauth_asset(&self.asset_urls, &self.base, key)
3824 }
3825 fn t(&self, source: &str) -> String {
3826 self.i18n.t(&Text::dynamic(source))
3827 }
3828 fn locale(&self) -> &str {
3829 self.i18n.locale()
3830 }
3831}
3832
3833#[derive(Template)]
3834#[template(path = "preferences.html")]
3835struct PreferencesTemplate {
3836 shell: Shell,
3837 zones: Vec<TzOption>,
3838 effective_tz: String,
3840 default_tz: String,
3842 inherits: bool,
3844 locales: Vec<LocaleOption>,
3845 default_locale: String,
3847 inherits_locale: bool,
3849 sessions: Vec<SessionRow>,
3851 password_changed: Option<String>,
3853 must_change: bool,
3855 min_password: i64,
3857}
3858
3859struct SessionRow {
3861 id: String,
3862 started: String,
3863 last_seen: String,
3864 expires: String,
3865 device: String,
3868 ip_address: String,
3870 current: bool,
3871}
3872
3873struct TzOption {
3874 name: String,
3875 selected: bool,
3876}
3877
3878struct LocaleOption {
3879 code: String,
3881 name: String,
3883 selected: bool,
3884}
3885
3886#[derive(Clone)]
3887struct NavView {
3888 label: String,
3889 path: String,
3890 active: bool,
3891 icon: String,
3894}
3895
3896pub(crate) fn render<T: Template>(template: T) -> Response {
3898 match template.render() {
3899 Ok(html) => Html(html).into_response(),
3900 Err(e) => AdminError::from(e).into_response(),
3902 }
3903}
3904
3905pub(crate) fn render_error() -> Response {
3906 error::masked_500()
3907}
3908
3909pub(crate) fn not_found() -> Response {
3910 AdminError::NotFound.into_response()
3911}
3912
3913pub(crate) fn forbidden() -> Response {
3914 AdminError::Forbidden.into_response()
3915}
3916
3917#[cfg(test)]
3918mod tests {
3919 use super::*;
3920
3921 fn write_form() -> form::FormConfig {
3922 form::FormConfig {
3923 enter: Default::default(),
3924 confirm_leave: true,
3925 focus: Default::default(),
3926 entity: "widgets".to_string(),
3927 title: "Widgets".into(),
3928 base_path: "/widgets".to_string(),
3929 id_field: "id".to_string(),
3930 fields: vec![],
3931 persist: None,
3932 timestamps: false,
3933 done_path: None,
3934 }
3935 }
3936
3937 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
3938 Resource {
3939 base_path: "/widgets".to_string(),
3940 nav_label: "Widgets".into(),
3941 list: backend_users_list_config(),
3942 form,
3943 permission: permission.map(str::to_string),
3944 import: None,
3945 relations: Vec::new(),
3946 }
3947 }
3948
3949 #[test]
3950 #[should_panic(expected = "must declare a permission")]
3951 fn write_resource_without_permission_aborts_boot() {
3952 let resource = resource_with(Some(write_form()), None);
3955 let _ = mount_resource(
3956 &resource,
3957 &field::builtin_registry(),
3958 &list::builtin_column_registry(),
3959 &std::collections::HashSet::new(),
3960 &persist::PersisterRegistry::new(),
3961 &[],
3962 );
3963 }
3964
3965 #[test]
3967 fn the_access_log_is_read_from_its_descriptor_file() {
3968 let access = builtin_resources()
3969 .into_iter()
3970 .find(|r| r.base_path == "/access-log")
3971 .expect("the access log is registered");
3972 assert_eq!(access.list.entity, "backend_access_log");
3973 assert_eq!(
3974 access.permission.as_deref(),
3975 Some("backend.view_access_log")
3976 );
3977 assert!(access.form.is_none(), "append-only");
3978 assert_eq!(
3979 access
3980 .list
3981 .columns
3982 .iter()
3983 .map(|c| c.field.as_str())
3984 .collect::<Vec<_>>(),
3985 [
3986 "created_at",
3987 "username_attempted",
3988 "event",
3989 "ip_address",
3990 "user_agent"
3991 ]
3992 );
3993 }
3994
3995 #[test]
3998 fn the_audit_log_is_read_from_its_descriptor_file() {
3999 let audit = builtin_resources()
4000 .into_iter()
4001 .find(|r| r.base_path == "/audit-log")
4002 .expect("the audit log is registered");
4003 assert_eq!(audit.list.entity, "backend_audit_log");
4004 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
4005 assert_eq!(
4006 audit
4007 .list
4008 .columns
4009 .iter()
4010 .map(|c| c.field.as_str())
4011 .collect::<Vec<_>>(),
4012 [
4013 "created_at",
4014 "actor_username",
4015 "action",
4016 "target_type",
4017 "target_id",
4018 "target_label"
4019 ],
4020 "in the order the file writes them"
4021 );
4022 assert_eq!(audit.list.columns[0].column_type, "datetime");
4023 assert_eq!(audit.list.per_page, 50);
4024 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
4025 assert!(audit.list.exportable, "evidence may leave the panel");
4026 assert!(audit.form.is_none(), "append-only: no form");
4027 assert!(!audit.list.deletable, "and nothing removes from it");
4028 }
4029
4030 #[test]
4031 #[should_panic(expected = "uses unregistered type `nope`")]
4032 fn unregistered_column_type_aborts_boot() {
4033 let mut resource = resource_with(None, None);
4034 let mut score = list::ListColumn::new("score", "Score");
4035 score.column_type = "nope".into();
4036 resource.list.columns.push(score);
4037 let _ = mount_resource(
4038 &resource,
4039 &field::builtin_registry(),
4040 &list::builtin_column_registry(),
4041 &std::collections::HashSet::new(),
4042 &persist::PersisterRegistry::new(),
4043 &[],
4044 );
4045 }
4046
4047 #[test]
4048 fn read_only_resource_may_omit_permission() {
4049 let resource = resource_with(None, None);
4051 let _ = mount_resource(
4052 &resource,
4053 &field::builtin_registry(),
4054 &list::builtin_column_registry(),
4055 &std::collections::HashSet::new(),
4056 &persist::PersisterRegistry::new(),
4057 &[],
4058 );
4059 }
4060
4061 #[test]
4062 fn shell_localizes_through_the_translator_and_falls_back() {
4063 let shell = Shell::test();
4065 assert_eq!(shell.t("Save"), "Save");
4066 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
4067 }
4068
4069 #[test]
4070 fn shell_tf_interpolates_integer_arguments() {
4071 let shell = Shell::test();
4072 assert_eq!(
4073 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
4074 "Page 2 of 5"
4075 );
4076 }
4077
4078 #[test]
4079 fn display_tz_prefers_a_valid_operator_preference() {
4080 assert_eq!(
4081 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
4082 Tz::Asia__Kolkata
4083 );
4084 }
4085
4086 #[test]
4087 fn display_tz_falls_back_when_unset_or_invalid() {
4088 let default = Tz::Europe__London;
4089 assert_eq!(resolve_display_tz(None, default), default);
4091 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
4093 assert_eq!(resolve_display_tz(Some(""), default), default);
4094 }
4095
4096 #[test]
4097 fn accept_language_orders_by_weight_and_drops_wildcard() {
4098 assert_eq!(
4099 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
4100 vec!["en-US", "en", "kn"]
4101 );
4102 assert_eq!(
4104 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
4105 vec!["kn", "en"]
4106 );
4107 }
4108
4109 #[test]
4110 fn locale_chain_prefers_operator_then_header_then_default() {
4111 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
4113 assert_eq!(
4116 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
4117 vec!["kn", "ta", "hi", "en"]
4118 );
4119 assert_eq!(
4121 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
4122 vec!["kn", "en", "ta"]
4123 );
4124 assert_eq!(
4126 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
4127 vec!["kn", "en"]
4128 );
4129 }
4130
4131 #[test]
4132 fn locale_chain_skips_unserveable_and_defaults_to_en() {
4133 let loaded = ["hi".to_string()];
4135 assert_eq!(
4138 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
4139 vec!["en"]
4140 );
4141 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
4143 assert_eq!(
4145 resolve_locale_chain(Some("hi"), None, "en", &loaded),
4146 vec!["hi", "en"]
4147 );
4148 assert_eq!(
4150 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
4151 vec![laterite_core::PSEUDO_LOCALE, "en"]
4152 );
4153 }
4154
4155 #[test]
4156 fn page_assets_dedupe_preserve_order_and_classify() {
4157 let mut reg = AssetRegistry::new();
4158 reg.insert(
4159 "a.js",
4160 AdminAsset {
4161 mime: "text/javascript; charset=utf-8",
4162 bytes: std::borrow::Cow::Borrowed(b"//"),
4163 },
4164 );
4165 reg.insert(
4166 "b.css",
4167 AdminAsset {
4168 mime: "text/css; charset=utf-8",
4169 bytes: std::borrow::Cow::Borrowed(b"body{}"),
4170 },
4171 );
4172 let urls = asset_urls(®);
4175 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
4176 assert_eq!(assets.len(), 2);
4177 assert_eq!(
4178 assets[0].url,
4179 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
4180 );
4181 assert!(!assets[0].css);
4182 assert_eq!(
4183 assets[1].url,
4184 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
4185 );
4186 assert!(assets[1].css);
4187 assert_ne!(assets[0].url, assets[1].url);
4190 }
4191
4192 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
4196 laterite_core::testing::connect_test(&[]).await
4197 }
4198
4199 #[tokio::test]
4200 async fn builtin_migrations_create_the_admin_tables() {
4201 let (db, _guard) = empty_db().await;
4202 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
4203 .await
4204 .unwrap();
4205 for table in ["backend_users", "settings"] {
4209 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
4210 .fetch_optional(&db.pool)
4211 .await;
4212 assert!(
4213 probe.is_ok(),
4214 "{table} should exist after builtin_migrations"
4215 );
4216 }
4217 }
4218
4219 #[tokio::test]
4220 async fn brand_setting_overrides_config_and_blank_falls_back() {
4221 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
4222 let state = AdminState {
4223 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
4224 db: db.clone(),
4225 nav: Arc::new(Vec::new()),
4226 settings: Arc::new(Vec::new()),
4227 permissions: Arc::new(builtin_permissions()),
4228 trusted_proxies: Arc::new(Vec::new()),
4229 icons: Arc::new(laterite_core::icons::IconSet::new()),
4230 sprite_url: Arc::from(""),
4231 admin_path: Arc::from("/admin"),
4232 secure_cookie: false,
4233 origin: Arc::from(""),
4234 timezone: Tz::UTC,
4235 default_locale: Arc::from("en"),
4236 catalogs: Arc::new(CatalogStore::default()),
4237 app_name: "Configured Name".to_string(),
4238 brand_cache: Arc::new(RwLock::new(None)),
4239 field_types: Arc::new(field::builtin_registry()),
4240 column_types: Arc::new(list::builtin_column_registry()),
4241 plugin_defined: Arc::new(laterite_core::Registry::new()),
4242 assets: Arc::new(builtin_assets()),
4243 asset_urls: Arc::new(asset_urls(&builtin_assets())),
4244 pickers: Arc::new(picker::PickerRegistry::new()),
4245 overrides: Arc::new(field::NoOverrides),
4246 };
4247
4248 assert_eq!(state.brand().await, "Configured Name");
4250
4251 settings::store::save(
4254 &db,
4255 &settings::BrandSetting {
4256 app_name: "Acme Corp".to_string(),
4257 },
4258 )
4259 .await
4260 .unwrap();
4261 state.invalidate_brand();
4262 assert_eq!(state.brand().await, "Acme Corp");
4263
4264 settings::store::save(
4266 &db,
4267 &settings::BrandSetting {
4268 app_name: " ".to_string(),
4269 },
4270 )
4271 .await
4272 .unwrap();
4273 state.invalidate_brand();
4274 assert_eq!(state.brand().await, "Configured Name");
4275 }
4276
4277 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
4278 settings::SettingsItem {
4279 code: code.to_string(),
4280 label: code.into(),
4281 description: String::new().into(),
4282 category: "General".into(),
4283 hint: None,
4284 confirm_leave: true,
4285 order: 1,
4286 icon: None,
4287 permission: permission.map(str::to_string),
4288 link: None,
4289 fields: Vec::new(),
4290 route: None,
4291 }
4292 }
4293
4294 #[test]
4295 fn settings_visibility_respects_permissions() {
4296 let items = vec![
4297 settings_item("public", None),
4298 settings_item("gated", Some("backend.manage_users")),
4299 ];
4300
4301 let none = PermissionSet::new(false, Vec::<String>::new());
4303 let codes: Vec<String> = visible_settings(&items, &none)
4304 .into_iter()
4305 .map(|i| i.code)
4306 .collect();
4307 assert_eq!(codes, ["public"]);
4308
4309 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
4311 assert_eq!(visible_settings(&items, &granted).len(), 2);
4312
4313 let superuser = PermissionSet::new(true, Vec::<String>::new());
4315 assert_eq!(visible_settings(&items, &superuser).len(), 2);
4316 }
4317
4318 #[test]
4319 fn context_sidebar_follows_settings_links() {
4320 let items = builtin_settings();
4321 let superuser = PermissionSet::new(true, Vec::<String>::new());
4322 let tr = Translator::new("en");
4323 let sidebar = |path: &str| {
4324 resolve_nav_context(
4325 &[],
4326 &items,
4327 "/admin",
4328 &superuser,
4329 path,
4330 &tr,
4331 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
4332 )
4333 .0
4334 };
4335 let active_path = |path: &str| -> Option<String> {
4336 sidebar(path)
4337 .into_iter()
4338 .flat_map(|g| g.items)
4339 .find(|i| i.active)
4340 .map(|i| i.path)
4341 };
4342
4343 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
4345 assert_eq!(
4346 active_path("/admin/roles/42/edit").as_deref(),
4347 Some("/admin/roles")
4348 );
4349 assert!(!sidebar("/admin/settings").is_empty());
4351 assert_eq!(active_path("/admin/settings"), None);
4352 assert_eq!(
4357 active_path("/admin/settings/branding").as_deref(),
4358 Some("/admin/settings/branding")
4359 );
4360 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
4363 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
4364 assert!(sidebar("/admin").is_empty());
4366 }
4367
4368 #[test]
4369 fn active_nav_lights_the_right_tab() {
4370 let nav = vec![
4371 NavLink {
4372 label: "Dashboard".into(),
4373 path: "/admin".to_string(),
4374 icon: Some("layout-dashboard"),
4375 },
4376 NavLink {
4377 label: "Pages".into(),
4378 path: "/admin/pages".to_string(),
4379 icon: None,
4380 },
4381 NavLink {
4382 label: "Settings".into(),
4383 path: "/admin/settings".to_string(),
4384 icon: Some("settings"),
4385 },
4386 ];
4387
4388 assert_eq!(
4390 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
4391 Some("/admin")
4392 );
4393 assert_eq!(
4395 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
4396 Some("/admin/pages")
4397 );
4398 assert_eq!(
4400 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
4401 None
4402 );
4403 assert_eq!(
4406 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
4407 None
4408 );
4409 assert_eq!(
4412 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
4413 Some("/admin/settings")
4414 );
4415 assert_eq!(
4416 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
4417 Some("/admin/settings")
4418 );
4419 }
4420}
4421
4422#[cfg(test)]
4423mod session_cookie_tests {
4424 use super::*;
4425
4426 #[test]
4430 fn the_session_cookie_never_outlives_the_browser() {
4431 let cookie = session_cookie("tok".into(), "/admin", false);
4432 assert_eq!(cookie.max_age(), None);
4433 assert!(cookie.http_only().unwrap_or(false));
4434 assert_eq!(cookie.path(), Some("/admin"));
4435 }
4436
4437 #[test]
4440 fn the_remember_cookie_carries_its_own_lifetime() {
4441 let credential = laterite_auth::RememberCredential {
4442 cookie: "sel:ver".into(),
4443 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
4444 };
4445 let cookie = remember_cookie(credential, "/admin", true);
4446 let age = cookie.max_age().expect("a remember cookie must persist");
4447 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
4449 assert!(cookie.http_only().unwrap_or(false));
4450 assert!(cookie.secure().unwrap_or(false));
4451 }
4452
4453 #[test]
4455 fn the_removal_matches_the_cookie_it_clears() {
4456 let removal = remember_removal("/admin");
4457 assert_eq!(removal.name(), REMEMBER_COOKIE);
4458 assert_eq!(removal.path(), Some("/admin"));
4459 }
4460}