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(crate) mod keyed;
31pub mod list;
32pub mod persist;
33pub mod picker;
34pub mod plugins;
35mod roles;
36mod routemap;
37pub mod routes;
38mod session;
39pub mod settings;
40mod sql;
41mod upload;
42mod users;
43
44pub use axum;
52
53pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
54pub use error::AdminError;
55pub use session::{FlashLevel, SessionHandle};
56pub use upload::VerifiedUpload;
57
58use std::collections::HashMap;
59use std::sync::{Arc, RwLock};
60
61use askama::Template;
62use axum::body::Body;
63use axum::extract::{Path, Query, Request, State};
64use axum::http::{header, HeaderMap};
65use axum::middleware::{self, Next};
66use axum::response::{Html, IntoResponse, Redirect, Response};
67use axum::routing::{any, get, post};
68use axum::{Extension, Form, Router};
69use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
70use chrono_tz::{Tz, TZ_VARIANTS};
71use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
72use laterite_core::{t, CatalogStore, Db, Text, Translator};
73use serde::{Deserialize, Serialize};
74
75pub trait AdminRegistry {
81 fn add_resource(&mut self, resource: Resource);
83 fn add_permission(&mut self, permission: Permission);
85 fn add_settings(&mut self, item: settings::SettingsItem);
87 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
89 fn add_persister(&mut self, persister: persist::PersisterReg);
91 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
93 fn add_screen(&mut self, screen: routes::ScreenReg);
96 fn add_public_route(&mut self, route: routes::PublicRouteReg);
99}
100
101impl AdminRegistry for laterite_core::Registry {
102 fn add_resource(&mut self, resource: Resource) {
103 self.add(resource);
104 }
105 fn add_permission(&mut self, permission: Permission) {
106 self.add(permission);
107 }
108 fn add_settings(&mut self, item: settings::SettingsItem) {
109 self.add(item);
110 }
111 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
112 self.add(source);
113 }
114 fn add_screen(&mut self, screen: routes::ScreenReg) {
115 self.add(screen);
116 }
117 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
118 self.add(route);
119 }
120 fn add_persister(&mut self, persister: persist::PersisterReg) {
121 self.add(persister);
122 }
123 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
124 self.add(field_type);
125 }
126}
127
128const SESSION_COOKIE: &str = "laterite_session";
129const REMEMBER_COOKIE: &str = "laterite_remember";
133
134pub(crate) const ICON_SPRITE: &str = "icons.svg";
137
138#[derive(Clone)]
140pub(crate) struct AdminState {
141 auth: AuthService,
142 db: Db,
143 nav: Arc<Vec<NavLink>>,
144 settings: Arc<Vec<settings::SettingsItem>>,
145 permissions: Arc<Vec<Permission>>,
146 admin_path: Arc<str>,
150 secure_cookie: bool,
151 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
153 icons: Arc<laterite_core::icons::IconSet>,
155 sprite_url: Arc<str>,
157 origin: Arc<str>,
159 timezone: Tz,
160 default_locale: Arc<str>,
164 catalogs: Arc<CatalogStore>,
167 app_name: String,
170 brand_cache: Arc<RwLock<Option<String>>>,
174 asset_urls: Arc<AssetUrls>,
176 field_types: Arc<field::FieldRegistry>,
179 column_types: Arc<list::ColumnRegistry>,
182 plugin_defined: Arc<laterite_core::Registry>,
185 assets: Arc<AssetRegistry>,
187 pickers: Arc<picker::PickerRegistry>,
190 overrides: Arc<dyn field::OverrideResolver>,
194}
195
196impl AdminState {
197 #[cfg(test)]
198 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
199 Self {
200 auth,
201 db,
202 nav: Arc::new(Vec::new()),
203 settings: Arc::new(Vec::new()),
204 permissions: Arc::new(builtin_permissions()),
205 trusted_proxies: Arc::new(Vec::new()),
206 icons: Arc::new(laterite_core::icons::IconSet::new()),
207 sprite_url: Arc::from(""),
208 admin_path: Arc::from("/admin"),
209 secure_cookie: false,
210 origin: Arc::from(""),
211 timezone: Tz::UTC,
212 default_locale: Arc::from("en"),
213 catalogs: Arc::new(CatalogStore::default()),
214 app_name: "Laterite".to_string(),
215 brand_cache: Arc::new(RwLock::new(None)),
216 field_types: Arc::new(field::builtin_registry()),
217 column_types: Arc::new(list::builtin_column_registry()),
218 plugin_defined: Arc::new(laterite_core::Registry::new()),
219 assets: Arc::new(builtin_assets()),
220 asset_urls: Arc::new(asset_urls(&builtin_assets())),
221 pickers: Arc::new(picker::PickerRegistry::new()),
222 overrides: Arc::new(field::NoOverrides),
223 }
224 }
225
226 async fn brand(&self) -> String {
230 {
231 let cached = self.brand_cache.read().unwrap().clone();
232 if let Some(name) = cached {
233 return name;
234 }
235 }
236 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
237 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
238 _ => self.app_name.clone(),
239 };
240 *self.brand_cache.write().unwrap() = Some(resolved.clone());
241 resolved
242 }
243
244 fn invalidate_brand(&self) {
246 *self.brand_cache.write().unwrap() = None;
247 }
248}
249
250#[derive(Clone)]
256#[non_exhaustive]
257pub struct AdminConfig {
258 pub secure_cookie: bool,
261 pub trusted_proxies: Vec<String>,
265 pub timezone: String,
269 pub locale: String,
273 pub app_name: String,
277 pub path: String,
281 pub origin: String,
286}
287
288impl Default for AdminConfig {
289 fn default() -> Self {
290 Self {
291 secure_cookie: false,
292 trusted_proxies: Vec::new(),
293 timezone: "UTC".to_string(),
294 locale: "en".to_string(),
295 app_name: "Laterite".to_string(),
296 path: "/admin".to_string(),
297 origin: String::new(),
298 }
299 }
300}
301
302#[derive(Clone)]
303struct NavLink {
304 label: Text,
305 path: String,
306 icon: Option<&'static str>,
309}
310
311#[derive(Clone)]
320pub struct Shell {
321 pub(crate) base: String,
325 brand: String,
329 nav: Vec<NavView>,
330 full_name: String,
331 initial: String,
332 tz: Tz,
336 i18n: Translator,
340 sidebar: Vec<settings::CategoryView>,
344 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
346 pub(crate) sprite_url: Arc<str>,
349 site_url: String,
353 asset_urls: Arc<AssetUrls>,
357 pub(crate) csrf_token: String,
361 pub(crate) flash: Vec<FlashLine>,
365 pub(crate) assets: Vec<PageAsset>,
369}
370
371#[derive(Template)]
373#[template(path = "page.html")]
374struct PageTemplate {
375 title: String,
376 body: html::Markup,
377 shell: Shell,
378}
379
380#[derive(Clone)]
382pub(crate) struct PageAsset {
383 pub(crate) url: String,
384 pub(crate) css: bool,
385}
386
387#[derive(Clone)]
390pub(crate) struct FlashLine {
391 pub(crate) level: session::FlashLevel,
392 pub(crate) text: String,
393 pub(crate) sticky: bool,
395}
396
397impl Shell {
398 #[allow(clippy::too_many_arguments)]
399 fn new(
400 base: &str,
401 brand: String,
402 nav: &[NavLink],
403 user: &AuthenticatedUser,
404 default_tz: Tz,
405 sidebar: Vec<settings::CategoryView>,
406 active_nav: Option<&str>,
407 csrf_token: String,
408 flash: Vec<session::Flash>,
409 i18n: Translator,
410 asset_urls: Arc<AssetUrls>,
411 site_url: String,
412 icon_set: Arc<laterite_core::icons::IconSet>,
413 sprite_url: Arc<str>,
414 ) -> Self {
415 let full_name = user.user.full_name();
416 let initial = full_name
417 .chars()
418 .next()
419 .map(|c| c.to_uppercase().to_string())
420 .unwrap_or_else(|| "?".to_string());
421 let flash = flash
424 .into_iter()
425 .map(|f| FlashLine {
426 sticky: f.sticky,
427 level: f.level,
428 text: i18n.t(&f.text),
429 })
430 .collect();
431 let nav = nav
432 .iter()
433 .map(|n| NavView {
434 label: i18n.t(&n.label),
435 path: n.path.clone(),
436 active: active_nav == Some(n.path.as_str()),
437 icon: n
438 .icon
439 .map(|name| icons::svg(&icon_set, &sprite_url, Some(name)))
440 .unwrap_or_default(),
441 })
442 .collect();
443 Shell {
444 asset_urls,
445 icon_set,
446 sprite_url,
447 site_url,
448 base: base.to_string(),
449 brand,
450 nav,
451 full_name,
452 initial,
453 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
454 i18n,
455 sidebar,
456 csrf_token,
457 flash,
458 assets: Vec::new(),
459 }
460 }
461
462 pub(crate) fn site_url(&self) -> &str {
464 &self.site_url
465 }
466
467 pub(crate) fn icon(&self, name: &str) -> String {
469 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
470 }
471
472 pub(crate) fn asset(&self, key: &str) -> String {
477 match self.asset_urls.get(key) {
478 Some(path) => format!("{}/assets/{path}", self.base),
479 None => {
480 debug_assert!(false, "asset key `{key}` is not registered");
481 format!("{}/assets/{key}", self.base)
482 }
483 }
484 }
485
486 pub fn t(&self, source: &str) -> String {
489 self.i18n.t(&Text::dynamic(source))
490 }
491
492 pub fn tt(&self, text: &Text) -> String {
495 self.i18n.t(text)
496 }
497
498 pub fn i18n(&self) -> &Translator {
501 &self.i18n
502 }
503
504 pub fn locale(&self) -> &str {
506 self.i18n.locale()
507 }
508
509 pub fn base(&self) -> &str {
511 &self.base
512 }
513
514 pub fn csrf_token(&self) -> &str {
518 &self.csrf_token
519 }
520
521 pub fn datetime(&self, utc: &str) -> String {
524 match chrono::DateTime::parse_from_rfc3339(utc) {
525 Ok(at) => at
526 .with_timezone(&self.tz)
527 .format("%Y-%m-%d %H:%M")
528 .to_string(),
529 Err(_) => utc.to_string(),
530 }
531 }
532
533 pub fn page(&self, title: &Text, body: html::Markup) -> Response {
537 render(PageTemplate {
538 title: self.tt(title),
539 body,
540 shell: self.clone(),
541 })
542 }
543
544 pub fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
548 let mut text = Text::dynamic(source);
549 for (name, value) in args {
550 text = text.arg(*name, *value);
551 }
552 self.i18n.t(&text)
553 }
554
555 pub fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
558 let mut text = Text::dynamic(source);
559 for (name, value) in args {
560 text = text.arg(*name, *value);
561 }
562 self.i18n.t(&text)
563 }
564
565 #[cfg(test)]
566 pub(crate) fn test() -> Self {
567 Shell {
568 asset_urls: Arc::new(asset_urls(&builtin_assets())),
569 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
570 sprite_url: Arc::from("/admin/assets/icons.svg"),
571 site_url: "http://localhost".to_string(),
572 base: "/admin".to_string(),
573 brand: "Laterite".to_string(),
574 nav: Vec::new(),
575 full_name: "Test Operator".to_string(),
576 initial: "T".to_string(),
577 tz: Tz::UTC,
578 i18n: Translator::new("en"),
579 sidebar: Vec::new(),
580 csrf_token: "test-csrf-token".to_string(),
581 flash: Vec::new(),
582 assets: Vec::new(),
583 }
584 }
585}
586
587fn settings_context(
594 visible: &[settings::SettingsItem],
595 admin_path: &str,
596 path: &str,
597) -> (bool, Option<String>) {
598 if path == format!("{admin_path}/settings") {
599 return (true, None);
600 }
601 let own = visible
607 .iter()
608 .filter(|i| i.link.is_none())
609 .find(|i| path == format!("{admin_path}{}", i.route()))
610 .map(|i| i.code.clone());
611 if own.is_some() {
612 return (true, own);
613 }
614 let linked = visible
615 .iter()
616 .filter_map(|i| {
617 i.link
618 .as_deref()
619 .map(|link| (format!("{admin_path}{link}"), &i.code))
620 })
621 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
622 .max_by_key(|(link, _)| link.len())
623 .map(|(_, code)| code.clone());
624 (linked.is_some(), linked)
625}
626
627fn active_nav_path(
635 nav: &[NavLink],
636 admin_path: &str,
637 in_settings_context: bool,
638 path: &str,
639) -> Option<String> {
640 if in_settings_context {
641 return Some(format!("{admin_path}/settings"));
642 }
643 nav.iter()
644 .filter(|n| {
645 path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
646 })
647 .max_by_key(|n| n.path.len())
648 .map(|n| n.path.clone())
649}
650
651fn resolve_nav_context(
656 nav: &[NavLink],
657 items: &[settings::SettingsItem],
658 admin_path: &str,
659 perms: &PermissionSet,
660 path: &str,
661 tr: &Translator,
662 icons: icons::Icons<'_>,
663) -> (Vec<settings::CategoryView>, Option<String>) {
664 let visible = visible_settings(items, perms);
665 let (in_context, active) = settings_context(&visible, admin_path, path);
666 let sidebar = if in_context {
667 settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons)
668 } else {
669 Vec::new()
670 };
671 let active_nav = active_nav_path(nav, admin_path, in_context, path);
672 (sidebar, active_nav)
673}
674
675fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
679 preference
680 .and_then(|name| name.parse::<Tz>().ok())
681 .unwrap_or(default_tz)
682}
683
684fn zone_offered(name: &str) -> bool {
686 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
687}
688
689fn base_lang(tag: &str) -> String {
692 tag.split(['-', '_'])
693 .next()
694 .unwrap_or(tag)
695 .to_ascii_lowercase()
696}
697
698fn is_serveable(base: &str, serveable: &[String]) -> bool {
704 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
705}
706
707fn default_locale(configured: &str, serveable: &[String]) -> String {
710 let base = base_lang(configured);
711 if is_serveable(&base, serveable) {
712 base
713 } else {
714 "en".to_string()
715 }
716}
717
718fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
722 let mut out = vec!["en".to_string()];
723 out.extend(catalogs.locales());
724 out
725}
726
727fn locale_name(code: &str) -> String {
732 if code == "en" {
733 "English".to_string()
734 } else {
735 code.to_string()
736 }
737}
738
739fn parse_accept_language(header: &str) -> Vec<String> {
742 let mut items: Vec<(f32, String)> = header
743 .split(',')
744 .filter_map(|part| {
745 let mut bits = part.split(';');
746 let tag = bits.next()?.trim();
747 if tag.is_empty() || tag == "*" {
748 return None;
749 }
750 let q = bits
751 .find_map(|p| p.trim().strip_prefix("q="))
752 .and_then(|q| q.parse::<f32>().ok())
753 .unwrap_or(1.0);
754 Some((q, tag.to_string()))
755 })
756 .collect();
757 items.sort_by(|a, b| b.0.total_cmp(&a.0));
759 items.into_iter().map(|(_, tag)| tag).collect()
760}
761
762fn resolve_locale_chain(
768 preference: Option<&str>,
769 accept_language: Option<&str>,
770 default: &str,
771 serveable: &[String],
772) -> Vec<String> {
773 let mut chain: Vec<String> = Vec::new();
774 let consider = |tag: &str, chain: &mut Vec<String>| {
775 let base = base_lang(tag);
776 if is_serveable(&base, serveable) && !chain.contains(&base) {
777 chain.push(base);
778 }
779 };
780 if let Some(pref) = preference {
781 consider(pref, &mut chain);
782 }
783 if let Some(header) = accept_language {
784 for tag in parse_accept_language(header) {
785 consider(&tag, &mut chain);
786 }
787 }
788 consider(default, &mut chain);
789 consider("en", &mut chain);
792 chain
793}
794
795fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
799 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
800 let serveable = state.catalogs.locales();
801 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
802 Translator::with_chain(chain, state.catalogs.clone())
803}
804
805#[derive(Serialize)]
813#[non_exhaustive]
814pub struct Resource {
815 pub base_path: String,
821 pub nav_label: Text,
823 pub list: list::ListConfig,
824 pub form: Option<form::FormConfig>,
825 pub permission: Option<String>,
832 pub import: Option<ImportConfig>,
836}
837
838#[derive(Clone, Debug, Default, Serialize, Deserialize)]
841#[non_exhaustive]
842pub struct ImportConfig {
843 #[serde(default)]
846 pub permission: Option<String>,
847}
848
849impl ImportConfig {
850 pub fn new() -> Self {
851 Self::default()
852 }
853
854 pub fn permission(mut self, code: impl Into<String>) -> Self {
857 self.permission = Some(code.into());
858 self
859 }
860}
861
862#[derive(Clone, Serialize)]
867#[non_exhaustive]
868pub struct Permission {
869 pub code: String,
870 pub label: Text,
872 pub group: Text,
873 pub default_roles: Vec<String>,
876 pub renamed_from: Vec<String>,
879}
880
881pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
882
883impl Permission {
884 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
887 Self {
888 code: code.into(),
889 label: label.into(),
890 group: group.into(),
891 default_roles: Vec::new(),
892 renamed_from: Vec::new(),
893 }
894 }
895
896 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
902 where
903 I: IntoIterator<Item = S>,
904 S: Into<String>,
905 {
906 self.renamed_from = codes.into_iter().map(Into::into).collect();
907 self
908 }
909
910 pub fn roles<I, S>(mut self, roles: I) -> Self
913 where
914 I: IntoIterator<Item = S>,
915 S: Into<String>,
916 {
917 self.default_roles = roles.into_iter().map(Into::into).collect();
918 self
919 }
920}
921
922pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
928 let permissions: Vec<Permission> = builtin_permissions()
929 .into_iter()
930 .chain(contributed.iter().cloned())
931 .collect();
932 let mut out: Vec<(String, String)> = Vec::new();
933 for permission in &permissions {
934 for old in &permission.renamed_from {
935 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
936 panic!(
937 "permissions `{taken}` and `{}` both claim the old code `{old}`",
938 permission.code
939 );
940 }
941 if permissions.iter().any(|p| &p.code == old) {
942 panic!(
943 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
944 permission.code
945 );
946 }
947 out.push((old.clone(), permission.code.clone()));
948 }
949 }
950 out
951}
952
953pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
959 let permissions: Vec<Permission> = builtin_permissions()
962 .into_iter()
963 .chain(contributed.iter().cloned())
964 .collect();
965 let held = |role: &str| -> Vec<String> {
966 permissions
967 .iter()
968 .filter(|p| {
969 if p.default_roles.is_empty() {
970 role == ROLE_ADMIN
971 } else {
972 p.default_roles.iter().any(|r| r == role)
973 }
974 })
975 .map(|p| p.code.clone())
976 .collect()
977 };
978 vec![
979 laterite_auth::store::SystemRole {
980 code: ROLE_ADMIN,
981 name: "Administrator",
982 description: "Administers the panel itself: operators, roles and settings.",
983 permissions: held(ROLE_ADMIN),
984 },
985 laterite_auth::store::SystemRole {
986 code: ROLE_EDITOR,
987 name: "Editor",
988 description: "Works with the content the application declares.",
989 permissions: held(ROLE_EDITOR),
990 },
991 ]
992}
993
994impl Resource {
995 pub fn new(
1000 base_path: impl Into<String>,
1001 nav_label: impl Into<Text>,
1002 list: list::ListConfig,
1003 ) -> Self {
1004 Self {
1005 base_path: base_path.into(),
1006 nav_label: nav_label.into(),
1007 list,
1008 form: None,
1009 permission: None,
1010 import: None,
1011 }
1012 }
1013
1014 pub fn import(mut self, import: ImportConfig) -> Self {
1017 self.import = Some(import);
1018 self
1019 }
1020
1021 pub fn form(mut self, form: form::FormConfig) -> Self {
1024 self.form = Some(form);
1025 self
1026 }
1027
1028 pub fn permission(mut self, permission: impl Into<String>) -> Self {
1030 self.permission = Some(permission.into());
1031 self
1032 }
1033}
1034
1035struct SystemRoleGuard;
1041
1042#[laterite_core::strata::async_trait]
1043impl laterite_core::ModelListener for SystemRoleGuard {
1044 async fn before_delete(
1045 &self,
1046 cx: &mut laterite_core::SaveCx<'_>,
1047 rec: &laterite_core::Record,
1048 ) -> Result<(), Text> {
1049 let Some(id) = rec.id() else {
1050 return Ok(());
1051 };
1052 let backend = cx.backend();
1054 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
1055 Ok(true) => Err(t!(
1056 "The framework maintains this role; it cannot be deleted."
1057 )),
1058 Err(_) => Err(t!(
1060 "Could not check whether this role is maintained by the framework."
1061 )),
1062 Ok(false) => Ok(()),
1063 }
1064 }
1065}
1066
1067fn builtin_permissions() -> Vec<Permission> {
1070 vec![
1073 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
1074 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
1075 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
1076 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
1077 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
1078 Permission::new("backend.view_access_log", "View the access log", "Backend"),
1079 ]
1080}
1081
1082pub fn descriptor_sources() -> Vec<String> {
1088 let mut out = Vec::new();
1089 out.extend(laterite_core::collect_sources(&builtin_resources()));
1090 out.extend(laterite_core::collect_sources(&builtin_settings()));
1091 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1092 out.sort();
1093 out.dedup();
1094 out
1095}
1096
1097pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1112 builtin_modules().iter().map(|m| m.migrations()).collect()
1113}
1114
1115pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1119 vec![
1120 Box::new(laterite_auth::AuthModule),
1121 Box::new(settings::SettingsModule),
1122 Box::new(plugins::PluginsModule),
1123 ]
1124}
1125
1126#[derive(Default)]
1150pub struct Contributions {
1151 pub resources: Vec<Resource>,
1153 pub settings: Vec<settings::SettingsItem>,
1155 pub permissions: Vec<Permission>,
1157 pub picker_sources: Vec<picker::PickerSourceReg>,
1159 pub persisters: Vec<persist::PersisterReg>,
1161 pub listeners: Vec<laterite_core::ModelListenerReg>,
1163 pub field_types: Vec<field::FieldTypeReg>,
1167 pub column_types: Vec<list::ColumnTypeReg>,
1168 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1171 pub screens: Vec<routes::ScreenReg>,
1173 pub public_routes: Vec<routes::PublicRouteReg>,
1175 pub plugin_defined: Arc<laterite_core::Registry>,
1180}
1181
1182pub fn router(
1183 auth: AuthService,
1184 db: Db,
1185 contributions: Contributions,
1186 config: AdminConfig,
1187 catalogs: Arc<CatalogStore>,
1188) -> Router {
1189 let Contributions {
1190 resources: app_resources,
1191 settings: app_settings,
1192 permissions: app_permissions,
1193 picker_sources: app_picker_sources,
1194 persisters: app_persisters,
1195 listeners: app_listeners,
1196 field_types: app_field_types,
1197 column_types: app_column_types,
1198 icons: app_icons,
1199 screens: app_screens,
1200 public_routes: app_public,
1201 plugin_defined,
1202 } = contributions;
1203 let admin_path = normalize_path(&config.path);
1204
1205 let mut resources = builtin_resources();
1206 let mut app_resources = app_resources;
1207 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1211 prefix_resource(&admin_path, resource);
1212 }
1213 for resource in app_resources.iter_mut() {
1217 if let Some(import) = &resource.import {
1218 let permission = import
1219 .permission
1220 .clone()
1221 .or_else(|| resource.permission.clone())
1222 .unwrap_or_else(|| {
1223 panic!(
1224 "admin resource `{}` declares `import` but no permission; importing \
1225 writes records, so it must be gated",
1226 resource.base_path
1227 )
1228 });
1229 resource.list.import = Some(routes::feature_link(
1230 &resource.base_path,
1231 "import",
1232 &permission,
1233 ));
1234 }
1235 }
1236
1237 let mut settings = builtin_settings();
1238 settings.extend(app_settings);
1239 let mut app_listeners = app_listeners;
1242 app_listeners.insert(
1243 0,
1244 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1245 );
1246
1247 let mut permissions = builtin_permissions();
1248 permissions.extend(app_permissions);
1249 let permission_codes: std::collections::HashSet<String> =
1251 permissions.iter().map(|p| p.code.clone()).collect();
1252
1253 let mut nav = vec![NavLink {
1257 label: "Dashboard".into(),
1258 path: admin_path.clone(),
1259 icon: Some("layout-dashboard"),
1260 }];
1261 for resource in &app_resources {
1262 nav.push(NavLink {
1263 label: resource.nav_label.clone(),
1264 path: resource.base_path.clone(),
1265 icon: None,
1266 });
1267 }
1268 for reg in &app_screens {
1271 if let Some(label) = ®.nav_label {
1272 nav.push(NavLink {
1273 label: label.clone(),
1274 path: format!("{admin_path}{}", reg.base_path),
1275 icon: None,
1276 });
1277 }
1278 }
1279 nav.push(NavLink {
1280 label: "Settings".into(),
1281 path: format!("{admin_path}/settings"),
1282 icon: Some("settings"),
1283 });
1284 resources.extend(app_resources);
1285
1286 let app_name = if config.app_name.trim().is_empty() {
1287 "Laterite".to_string()
1288 } else {
1289 config.app_name.clone()
1290 };
1291
1292 let mut pickers = picker::PickerRegistry::new();
1295 for reg in app_picker_sources {
1296 let name = reg.name.clone();
1297 if !field::is_name(&name, true) {
1298 panic!("invalid picker source name `{name}`");
1299 }
1300 if pickers.insert(name.clone(), reg).is_some() {
1301 panic!("duplicate picker source `{name}`");
1302 }
1303 }
1304
1305 let pickers = Arc::new(pickers);
1309 let mut field_types = field::builtin_registry();
1310 field_types.insert(
1311 "reference".to_string(),
1312 Arc::new(field::RefPickerField::new(pickers.clone())),
1313 );
1314
1315 let mut persisters = persist::PersisterRegistry::new();
1318 for reg in app_persisters {
1319 let name = reg.name.clone();
1320 if !field::is_name(&name, true) {
1321 panic!("invalid persister name `{name}`");
1322 }
1323 if persisters.insert(name.clone(), reg.persister).is_some() {
1324 panic!("duplicate persister `{name}`");
1325 }
1326 }
1327
1328 for reg in app_field_types {
1334 let name = reg.name().to_string();
1335 if !field::is_name(&name, true) {
1336 panic!("invalid field type name `{name}`");
1337 }
1338 if field_types.insert(name.clone(), reg.field_type).is_some() {
1339 panic!("duplicate field type `{name}`");
1340 }
1341 }
1342
1343 let mut icon_set = laterite_core::icons::IconSet::new();
1347 for (owner, reg) in app_icons {
1348 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1349 panic!("module `{owner}` contributed an invalid icon: {e}");
1350 }
1351 }
1352
1353 let mut assets = builtin_assets();
1356 assets.insert(
1357 ICON_SPRITE,
1358 AdminAsset {
1359 mime: "image/svg+xml",
1360 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1361 },
1362 );
1363
1364 for item in &settings {
1368 icons::require(
1369 &icon_set,
1370 &format!("settings item `{}`", item.code),
1371 item.icon.as_deref(),
1372 );
1373 }
1374 for link in &nav {
1375 icons::require(&icon_set, &format!("menu entry `{}`", link.path), link.icon);
1376 }
1377
1378 let mut column_types = list::builtin_column_registry();
1382 for reg in app_column_types {
1383 let name = reg.name().to_string();
1384 if !field::is_name(&name, true) {
1385 panic!("invalid column type name `{name}`");
1386 }
1387 if column_types.insert(name.clone(), reg.column_type).is_some() {
1388 panic!("duplicate column type `{name}`");
1389 }
1390 }
1391
1392 let settings = Arc::new(settings);
1393 let asset_urls_arc = Arc::new(asset_urls(&assets));
1395 let sprite_url: Arc<str> = Arc::from(
1396 asset_urls_arc
1397 .get(ICON_SPRITE)
1398 .map(|p| format!("{admin_path}/assets/{p}"))
1399 .unwrap_or_default()
1400 .as_str(),
1401 );
1402 let icons_arc = Arc::new(icon_set);
1403 let state = AdminState {
1404 auth,
1405 db,
1406 nav: Arc::new(nav),
1407 settings: settings.clone(),
1408 permissions: Arc::new(permissions),
1409 admin_path: Arc::from(admin_path.as_str()),
1410 secure_cookie: config.secure_cookie,
1411 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1412 icons: icons_arc.clone(),
1413 sprite_url: sprite_url.clone(),
1414 origin: Arc::from(config.origin.trim_end_matches('/')),
1415 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1416 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1417 catalogs,
1418 app_name,
1419 brand_cache: Arc::new(RwLock::new(None)),
1420 field_types: Arc::new(field_types),
1421 column_types: Arc::new(column_types),
1422 plugin_defined,
1423 assets: Arc::new(assets),
1424 asset_urls: asset_urls_arc.clone(),
1425 pickers,
1426 overrides: Arc::new(field::NoOverrides),
1427 };
1428
1429 let mut protected = Router::new().route(&admin_path, get(dashboard));
1430 let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
1434 for resource in &resources {
1435 let (mounted, prepared) = mount_resource(
1436 resource,
1437 &state.field_types,
1438 &state.column_types,
1439 &permission_codes,
1440 &persisters,
1441 &app_listeners,
1442 );
1443 protected = protected.merge(mounted);
1444 if let Some(prepared) = prepared {
1445 let key = resource
1446 .base_path
1447 .strip_prefix(admin_path.as_str())
1448 .unwrap_or(&resource.base_path);
1449 forms.insert(key.to_string(), prepared);
1450 }
1451 }
1452 let forms: form::Forms = Arc::new(forms);
1453 for resource in &resources {
1457 let Some(link) = &resource.list.import else {
1458 continue;
1459 };
1460 let key = resource
1461 .base_path
1462 .strip_prefix(admin_path.as_str())
1463 .unwrap_or(&resource.base_path)
1464 .to_string();
1465 let prepared = forms.get(&key).unwrap_or_else(|| {
1466 panic!(
1467 "admin resource `{}` declares `import` but has no form to import through",
1468 resource.base_path
1469 )
1470 });
1471 let feature = state
1472 .plugin_defined
1473 .items::<routes::ResourceFeatureReg>()
1474 .into_iter()
1475 .find(|reg| reg.key == "import")
1476 .unwrap_or_else(|| {
1477 panic!(
1478 "admin resource `{}` declares `import`, but no module provides it; \
1479 register the import module",
1480 resource.base_path
1481 )
1482 });
1483 let base = link.href.clone();
1484 let ctx = routes::RouteCtx::new(
1485 state.db.clone(),
1486 &base,
1487 &admin_path,
1488 &state.origin,
1489 state.plugin_defined.clone(),
1490 state.auth.events().clone(),
1491 forms.clone(),
1492 );
1493 let info = routes::ResourceInfo::new(
1494 key,
1495 resource.base_path.clone(),
1496 resource.nav_label.clone(),
1497 link.permission.clone(),
1498 form::FormHandle::new(state.db.clone(), prepared.clone()),
1499 );
1500 protected = protected.merge(guard_with_permission(
1501 Router::new().nest_service(&base, feature.feature.mount(&ctx, &info)),
1502 &link.permission,
1503 ));
1504 }
1505 for reg in &app_screens {
1509 let base = format!("{admin_path}{}", reg.base_path);
1510 let ctx = routes::RouteCtx::new(
1511 state.db.clone(),
1512 &base,
1513 &admin_path,
1514 &state.origin,
1515 state.plugin_defined.clone(),
1516 state.auth.events().clone(),
1517 forms.clone(),
1518 );
1519 protected = protected.merge(guard_with_permission(
1520 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1521 ®.permission,
1522 ));
1523 }
1524
1525 protected = protected.merge(guard_with_permission(
1528 Router::new()
1529 .route(
1530 &format!("{admin_path}/roles/new"),
1531 get(roles::new_form).post(roles::create),
1532 )
1533 .route(
1534 &format!("{admin_path}/roles/{{id}}/edit"),
1535 get(roles::edit_form).post(roles::update),
1536 ),
1537 "backend.manage_roles",
1538 ));
1539 protected = protected.merge(guard_with_permission(
1542 Router::new()
1543 .route(
1544 &format!("{admin_path}/users/new"),
1545 get(users::new_form).post(users::create),
1546 )
1547 .route(
1548 &format!("{admin_path}/users/{{id}}/edit"),
1549 get(users::edit_form).post(users::update),
1550 )
1551 .route(
1552 &format!("{admin_path}/users/{{id}}/active"),
1553 post(users::set_active),
1554 )
1555 .route(
1556 &format!("{admin_path}/users/{{id}}/password"),
1557 post(users::reset_password),
1558 )
1559 .route(
1560 &format!("{admin_path}/users/{{id}}/unlock"),
1561 post(users::unlock),
1562 ),
1563 "backend.manage_users",
1564 ));
1565 protected = protected.merge(guard_with_permission(
1568 Router::new()
1569 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1570 .route(
1571 &format!("{admin_path}/plugins/toggle"),
1572 post(plugins::toggle),
1573 ),
1574 plugins::MANAGE_PERMISSION,
1575 ));
1576 protected = protected
1577 .route(&format!("{admin_path}/settings"), get(settings_index))
1578 .route(
1579 &format!("{admin_path}/preferences"),
1580 get(preferences_form).post(preferences_update),
1581 )
1582 .route(
1583 &format!("{admin_path}/preferences/password"),
1584 post(password_update),
1585 )
1586 .route(
1587 &format!("{admin_path}/preferences/sessions/revoke"),
1588 post(session_revoke),
1589 )
1590 .route(
1591 &format!("{admin_path}/preferences/sessions/revoke-others"),
1592 post(session_revoke_others),
1593 )
1594 .route(
1597 &format!("{admin_path}/pickers/{{source}}/search"),
1598 any(picker::search),
1599 )
1600 .route(
1601 &format!("{admin_path}/pickers/{{source}}/resolve"),
1602 any(picker::resolve),
1603 )
1604 .route(&format!("{admin_path}/logout"), post(logout));
1605
1606 for item in settings.iter().filter(|i| i.link.is_none()) {
1610 let path = format!("{admin_path}{}", item.route());
1611 let code = item.code.clone();
1612 let edit_code = code.clone();
1613 protected = protected.route(
1614 &path,
1615 get(
1616 move |State(state): State<AdminState>,
1617 Extension(shell): Extension<Shell>,
1618 Extension(user): Extension<AuthenticatedUser>| {
1619 let code = edit_code.clone();
1620 async move { settings_edit(state, shell, user, code).await }
1621 },
1622 )
1623 .post(
1624 move |State(state): State<AdminState>,
1625 Extension(shell): Extension<Shell>,
1626 Extension(user): Extension<AuthenticatedUser>,
1627 Extension(session): Extension<session::SessionHandle>,
1628 Form(data): Form<HashMap<String, String>>| {
1629 let code = code.clone();
1630 async move { settings_update(state, shell, user, session, code, data).await }
1631 },
1632 ),
1633 );
1634 }
1635
1636 protected
1637 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1638 .route(
1642 &format!("{admin_path}/login"),
1643 get(login_form).post(login_submit),
1644 )
1645 .route(
1646 &format!("{admin_path}/setup"),
1647 get(setup_form).post(setup_submit),
1648 )
1649 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1650 .merge(mount_public(
1654 &app_public,
1655 &state.db,
1656 &admin_path,
1657 &state.origin,
1658 &state.plugin_defined,
1659 state.auth.events(),
1660 &forms,
1661 ))
1662 .fallback(not_found_fallback)
1664 .layer(middleware::from_fn_with_state(
1667 state.clone(),
1668 enforce_origin,
1669 ))
1670 .layer(middleware::from_fn_with_state(
1673 state.clone(),
1674 capture_client,
1675 ))
1676 .layer(
1681 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1682 header::CACHE_CONTROL,
1683 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1684 ),
1685 )
1686 .layer(
1690 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1691 header::VARY,
1692 axum::http::HeaderValue::from_static("HX-Request"),
1693 ),
1694 )
1695 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1696 handle_panic,
1697 ))
1698 .with_state(state)
1699}
1700
1701async fn not_found_fallback() -> AdminError {
1703 AdminError::NotFound
1704}
1705
1706fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1708 let cause = err
1709 .downcast_ref::<&str>()
1710 .map(|s| s.to_string())
1711 .or_else(|| err.downcast_ref::<String>().cloned())
1712 .unwrap_or_else(|| "unknown panic".to_string());
1713 tracing::error!(panic = cause, "admin handler panicked");
1714 error::masked_500()
1715}
1716
1717pub fn normalize_path(path: &str) -> String {
1722 let trimmed = path.trim().trim_matches('/');
1723 if trimmed.is_empty() {
1724 "/admin".to_string()
1725 } else {
1726 format!("/{trimmed}")
1727 }
1728}
1729
1730fn mount_public(
1734 regs: &[routes::PublicRouteReg],
1735 db: &Db,
1736 admin_path: &str,
1737 origin: &str,
1738 plugin_defined: &Arc<laterite_core::Registry>,
1739 events: &laterite_core::Events,
1740 forms: &form::Forms,
1741) -> Router<AdminState> {
1742 let mut router = Router::new();
1743 for reg in regs {
1744 let ctx = routes::RouteCtx::new(
1745 db.clone(),
1746 ®.path,
1747 admin_path,
1748 origin,
1749 plugin_defined.clone(),
1750 events.clone(),
1751 forms.clone(),
1752 );
1753 router = router.nest_service(
1758 ®.path,
1759 reg.route.mount(&ctx).fallback(not_found_fallback),
1760 );
1761 }
1762 router
1763}
1764
1765pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1768 let old = resource.base_path.clone();
1769 resource.base_path = base.to_string();
1770 if let Some(edit_base) = &mut resource.list.edit_base {
1771 if edit_base == &old {
1772 *edit_base = base.to_string();
1773 }
1774 }
1775 if let Some(form) = &mut resource.form {
1776 if form.base_path == old {
1777 form.base_path = base.to_string();
1778 }
1779 }
1780}
1781
1782fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1783 resource.base_path = format!("{admin_path}{}", resource.base_path);
1784 if let Some(edit_base) = &mut resource.list.edit_base {
1785 *edit_base = format!("{admin_path}{edit_base}");
1786 }
1787 if let Some(form) = &mut resource.form {
1788 form.base_path = format!("{admin_path}{}", form.base_path);
1789 }
1790}
1791
1792pub(crate) struct AdminAsset {
1794 pub mime: &'static str,
1795 pub bytes: std::borrow::Cow<'static, [u8]>,
1799}
1800
1801pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1804
1805pub(crate) type AssetUrls = HashMap<&'static str, String>;
1809
1810pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
1812 registry
1813 .iter()
1814 .map(|(&key, asset)| {
1815 (
1816 key,
1817 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
1818 )
1819 })
1820 .collect()
1821}
1822
1823pub(crate) fn page_assets(
1830 keys: &[&str],
1831 base: &str,
1832 registry: &AssetRegistry,
1833 urls: &AssetUrls,
1834) -> Vec<PageAsset> {
1835 let mut seen = std::collections::HashSet::new();
1836 let mut out = Vec::new();
1837 for &key in keys {
1838 if !seen.insert(key) {
1839 continue;
1840 }
1841 match (registry.get(key), urls.get(key)) {
1842 (Some(asset), Some(path)) => out.push(PageAsset {
1843 url: format!("{base}/assets/{path}"),
1844 css: asset.mime.contains("css"),
1845 }),
1846 _ => debug_assert!(false, "asset key `{key}` is not registered"),
1847 }
1848 }
1849 out
1850}
1851
1852pub(crate) fn builtin_assets() -> AssetRegistry {
1861 let font = |bytes: &'static [u8]| AdminAsset {
1862 mime: "font/woff2",
1863 bytes: std::borrow::Cow::Borrowed(bytes),
1864 };
1865 HashMap::from([
1866 (
1867 "laterite.css",
1868 AdminAsset {
1869 mime: "text/css; charset=utf-8",
1870 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
1871 },
1872 ),
1873 (
1874 "laterite.js",
1875 AdminAsset {
1876 mime: "text/javascript; charset=utf-8",
1877 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
1878 },
1879 ),
1880 (
1881 "vendor/htmx.min.js",
1882 AdminAsset {
1883 mime: "text/javascript; charset=utf-8",
1884 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
1885 },
1886 ),
1887 (
1888 "fields/ref-picker.js",
1889 AdminAsset {
1890 mime: "text/javascript; charset=utf-8",
1891 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
1892 },
1893 ),
1894 (
1895 "fields/ref-picker.css",
1896 AdminAsset {
1897 mime: "text/css; charset=utf-8",
1898 bytes: std::borrow::Cow::Borrowed(include_bytes!(
1899 "../assets/fields/ref-picker.css"
1900 )),
1901 },
1902 ),
1903 (
1904 "mark.svg",
1905 AdminAsset {
1906 mime: "image/svg+xml",
1907 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
1908 },
1909 ),
1910 (
1911 "mark.png",
1912 AdminAsset {
1913 mime: "image/png",
1914 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
1915 },
1916 ),
1917 (
1918 "fonts/space-grotesk-500.woff2",
1919 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1920 ),
1921 (
1922 "fonts/space-grotesk-600.woff2",
1923 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1924 ),
1925 (
1926 "fonts/space-grotesk-700.woff2",
1927 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1928 ),
1929 (
1930 "fonts/ibm-plex-sans-400.woff2",
1931 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1932 ),
1933 (
1934 "fonts/ibm-plex-sans-600.woff2",
1935 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1936 ),
1937 (
1938 "fonts/ibm-plex-mono-400.woff2",
1939 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1940 ),
1941 (
1942 "fonts/ibm-plex-mono-600.woff2",
1943 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1944 ),
1945 ])
1946}
1947
1948async fn serve_asset(
1950 State(state): State<AdminState>,
1951 headers: HeaderMap,
1952 Path(path): Path<String>,
1953) -> Response {
1954 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
1958 let Some(asset) = state.assets.get(key.as_str()) else {
1959 return not_found();
1960 };
1961 let policy = if fingerprinted {
1962 http_cache::IMMUTABLE
1963 } else {
1964 http_cache::REVALIDATE
1965 };
1966 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
1967}
1968
1969fn mount_resource(
1974 resource: &Resource,
1975 field_types: &field::FieldRegistry,
1976 column_types: &list::ColumnRegistry,
1977 permission_codes: &std::collections::HashSet<String>,
1978 persisters: &persist::PersisterRegistry,
1979 listeners: &[laterite_core::ModelListenerReg],
1980) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
1981 list::check(&resource.list, column_types, permission_codes)
1985 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
1986 if resource.form.is_some() && resource.permission.is_none() {
1991 panic!(
1992 "admin resource `{}` has a create/edit form but no permission; a write \
1993 resource must declare a permission",
1994 resource.base_path
1995 );
1996 }
1997 let base = resource.base_path.clone();
1998 let list_path = base.clone();
1999 let list_cfg = resource.list.clone();
2000 let mut router = Router::new().route(
2001 &base,
2002 get(
2003 move |State(state): State<AdminState>,
2004 Extension(shell): Extension<Shell>,
2005 Query(params): Query<list::ListParams>,
2006 Query(raw): Query<std::collections::HashMap<String, String>>,
2007 axum::extract::RawQuery(query_string): axum::extract::RawQuery,
2008 Extension(user): Extension<AuthenticatedUser>,
2009 Extension(session): Extension<session::SessionHandle>,
2010 headers: axum::http::HeaderMap| {
2011 let cfg = list_cfg.clone();
2012 let path = list_path.clone();
2013 async move {
2014 list::handle_remembering(
2015 &state,
2016 &cfg,
2017 &path,
2018 params,
2019 &raw,
2020 &user,
2021 shell,
2022 &headers,
2023 query_string.as_deref(),
2024 Some(&session),
2025 )
2026 .await
2027 }
2028 },
2029 ),
2030 );
2031
2032 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
2035 if resource.list.exportable {
2036 router = router.route(
2037 &format!("{base}/export"),
2038 get(
2039 move |state: State<AdminState>,
2040 Query(raw): Query<std::collections::HashMap<String, String>>,
2041 Extension(user): Extension<AuthenticatedUser>,
2042 Extension(shell): Extension<Shell>,
2043 Extension(session): Extension<session::SessionHandle>| {
2044 let cfg = export_cfg.clone();
2045 let path = export_path.clone();
2046 async move {
2047 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
2048 }
2049 },
2050 ),
2051 );
2052 }
2053
2054 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
2055 router = router.route(
2056 &format!("{base}/columns"),
2057 post(
2058 move |State(state): State<AdminState>,
2059 Extension(user): Extension<AuthenticatedUser>,
2060 Extension(session): Extension<session::SessionHandle>,
2061 headers: axum::http::HeaderMap,
2062 Form(pairs): Form<Vec<(String, String)>>| {
2063 let cfg = columns_cfg.clone();
2064 let path = columns_path.clone();
2065 async move {
2066 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
2067 }
2068 },
2069 ),
2070 );
2071
2072 if resource.list.deletable {
2073 let persister: Arc<dyn persist::Persister> =
2077 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
2078 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
2079 panic!(
2080 "admin resource `{}`: unregistered persister `{key}`",
2081 resource.base_path
2082 )
2083 }),
2084 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
2085 };
2086 let ctx = Arc::new(bulk::BulkContext {
2087 base_path: resource.base_path.clone(),
2088 entity: resource.list.entity.clone(),
2089 persister,
2090 listeners: listeners
2091 .iter()
2092 .filter(|reg| reg.matches(&resource.list.entity))
2093 .map(|reg| reg.listener.clone())
2094 .collect(),
2095 });
2096 router =
2097 router.route(
2098 &format!("{base}/delete"),
2099 post(
2100 move |state: State<AdminState>,
2101 user: Extension<AuthenticatedUser>,
2102 session: Extension<session::SessionHandle>,
2103 headers: axum::http::HeaderMap,
2104 form: Form<Vec<(String, String)>>| {
2105 let ctx = ctx.clone();
2106 async move {
2107 bulk::delete_handler(state, user, session, ctx, headers, form).await
2108 }
2109 },
2110 ),
2111 );
2112 }
2113
2114 let mut prepared_form = None;
2115 if let Some(form_cfg) = resource.form.clone() {
2116 let prepared = Arc::new(
2119 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
2120 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
2121 );
2122 prepared_form = Some(prepared.clone());
2123 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
2124 router = router.route(
2125 &format!("{base}/new"),
2126 get(
2127 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
2128 let pf = new_pf.clone();
2129 async move { form::new_form(&state, &pf, shell) }
2130 },
2131 )
2132 .post(
2133 move |State(state): State<AdminState>,
2134 Extension(shell): Extension<Shell>,
2135 Extension(user): Extension<AuthenticatedUser>,
2136 Extension(session): Extension<session::SessionHandle>,
2137 headers: axum::http::HeaderMap,
2138 Form(data): Form<HashMap<String, String>>| {
2139 let pf = create_pf.clone();
2140 async move {
2141 form::create(&state, &pf, data, shell, &user, &session, &headers).await
2142 }
2143 },
2144 ),
2145 );
2146
2147 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
2148 router = router.route(
2149 &format!("{base}/{{id}}/edit"),
2150 get(
2151 move |State(state): State<AdminState>,
2152 Extension(shell): Extension<Shell>,
2153 Path(id): Path<String>| {
2154 let pf = edit_pf.clone();
2155 async move { form::edit_form(&state, &pf, id, shell).await }
2156 },
2157 )
2158 .post(
2159 move |State(state): State<AdminState>,
2160 Extension(shell): Extension<Shell>,
2161 Extension(user): Extension<AuthenticatedUser>,
2162 Extension(session): Extension<session::SessionHandle>,
2163 Path(id): Path<String>,
2164 headers: axum::http::HeaderMap,
2165 Form(data): Form<HashMap<String, String>>| {
2166 let pf = update_pf.clone();
2167 async move {
2168 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
2169 }
2170 },
2171 ),
2172 );
2173 }
2174
2175 if let Some(permission) = &resource.permission {
2177 router = guard_with_permission(router, permission);
2178 }
2179 (router, prepared_form)
2180}
2181
2182fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
2187 let needed: Arc<str> = Arc::from(permission);
2188 router.route_layer(middleware::from_fn(
2189 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
2190 let needed = needed.clone();
2191 async move {
2192 if user.allows(&needed) {
2193 next.run(req).await
2194 } else {
2195 forbidden()
2196 }
2197 }
2198 },
2199 ))
2200}
2201
2202async fn require_auth(
2208 State(state): State<AdminState>,
2209 jar: CookieJar,
2210 request: Request,
2211 next: Next,
2212) -> Response {
2213 let login = format!("{}/login", state.admin_path);
2214 let client_ctx = request
2215 .extensions()
2216 .get::<RequestContext>()
2217 .cloned()
2218 .unwrap_or_default();
2219 let mut ended: Option<laterite_auth::SessionEnd> = None;
2224 let live = match jar.get(SESSION_COOKIE) {
2225 Some(cookie) => {
2226 let token = cookie.value().to_string();
2227 match state.auth.resolve_session(&token).await {
2228 Ok(resolved) => Some((token, resolved)),
2229 Err(_) => {
2230 ended = state.auth.session_end(&token).await.ok().flatten();
2231 None
2232 }
2233 }
2234 }
2235 None => None,
2236 };
2237 let htmx = request.headers().contains_key("hx-request");
2238 let (token, resolved, recalled) = match live {
2239 Some((token, resolved)) => (token, resolved, None),
2240 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2243 return signed_out(&state, jar, &login, ended, htmx);
2244 }
2245 None => match recall(&state, &jar, &client_ctx).await {
2246 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2247 None => {
2248 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2252 if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
2253 ended = Some(laterite_auth::SessionEnd::Revoked(reason));
2254 }
2255 }
2256 return signed_out(&state, jar, &login, ended, htmx);
2257 }
2258 },
2259 };
2260 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2261
2262 let mut request = request;
2267 let safe = session::is_safe_method(request.method());
2268 let mut deferred: Option<session::CsrfPending> = None;
2270 if !safe {
2271 let (parts, body) = request.into_parts();
2272 let (body, submitted) = if session::is_multipart(&parts.headers) {
2279 let token = session::header_token(&parts.headers)
2280 .or_else(|| session::query_token(parts.uri.query()));
2281 if token.is_none() {
2286 deferred = Some(session::CsrfPending::default());
2287 }
2288 (body, token)
2289 } else {
2290 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2291 .await
2292 .unwrap_or_default();
2293 let token = session::submitted_token(&parts.headers, &bytes);
2294 (Body::from(bytes), token)
2295 };
2296 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2297 {
2298 tracing::warn!(
2299 user_id = resolved.identity.user.id,
2300 "admin CSRF check failed"
2301 );
2302 return error::csrf_rejected();
2303 }
2304 request = Request::from_parts(parts, body);
2305 }
2306
2307 let user = resolved.identity;
2308 let acting_user_id = user.user.id;
2310 let path = request.uri().path().to_string();
2311 let preferences = format!("{}/preferences", state.admin_path);
2314 if !path.starts_with(&preferences)
2315 && path != format!("{}/logout", state.admin_path)
2316 && state
2317 .auth
2318 .must_change_password(user.user.id)
2319 .await
2320 .unwrap_or(false)
2321 {
2322 let to = format!("{preferences}#password");
2323 let response = if htmx {
2324 (
2325 [("HX-Redirect", to.as_str())],
2326 axum::http::StatusCode::NO_CONTENT,
2327 )
2328 .into_response()
2329 } else {
2330 Redirect::to(&to).into_response()
2331 };
2332 return with_recall_cookies(response, recalled, jar, token, &state);
2333 }
2334 let flash = if safe {
2337 handle.take_flash()
2338 } else {
2339 Vec::new()
2340 };
2341 let accept_language = request
2345 .headers()
2346 .get("accept-language")
2347 .and_then(|v| v.to_str().ok());
2348 let serveable = state.catalogs.locales();
2349 let chain = resolve_locale_chain(
2350 user.user.locale.as_deref(),
2351 accept_language,
2352 &state.default_locale,
2353 &serveable,
2354 );
2355 let i18n = Translator::with_chain(chain, state.catalogs.clone());
2356 let translator = i18n.clone();
2359 let (sidebar, active_nav) = resolve_nav_context(
2360 &state.nav,
2361 &state.settings,
2362 &state.admin_path,
2363 &user.permissions,
2364 &path,
2365 &i18n,
2366 icons::Icons::new(&state.icons, &state.sprite_url),
2367 );
2368 let brand = state.brand().await;
2369 let shell = Shell::new(
2370 &state.admin_path,
2371 brand,
2372 &state.nav,
2373 &user,
2374 state.timezone,
2375 sidebar,
2376 active_nav.as_deref(),
2377 handle.csrf_token(),
2378 flash,
2379 i18n,
2380 state.asset_urls.clone(),
2381 state.origin.to_string(),
2382 state.icons.clone(),
2383 state.sprite_url.clone(),
2384 );
2385 request.extensions_mut().insert(user);
2386 request.extensions_mut().insert(shell);
2387 request.extensions_mut().insert(handle.clone());
2388 if let Some(pending) = &deferred {
2390 request.extensions_mut().insert(pending.clone());
2391 }
2392 let mut response = next.run(request).await;
2393
2394 if let Some(pending) = &deferred {
2399 if !pending.was_satisfied() {
2400 tracing::error!(
2401 user_id = acting_user_id,
2402 "a multipart route returned without verifying its request token"
2403 );
2404 return error::csrf_rejected();
2405 }
2406 }
2407
2408 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2412 response = error::localized_error(meta.kind, &translator);
2413 }
2414
2415 if let Some(blob) = handle.dirty_blob() {
2418 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2419 tracing::error!(error = %e, "persisting admin session failed");
2420 }
2421 }
2422 with_recall_cookies(response, recalled, jar, token, &state)
2423}
2424
2425fn with_recall_cookies(
2429 response: Response,
2430 recalled: Option<laterite_auth::RememberCredential>,
2431 jar: CookieJar,
2432 token: String,
2433 state: &AdminState,
2434) -> Response {
2435 match recalled {
2436 Some(credential) => {
2437 let jar = jar
2438 .add(session_cookie(
2439 token,
2440 &state.admin_path,
2441 state.secure_cookie,
2442 ))
2443 .add(remember_cookie(
2444 credential,
2445 &state.admin_path,
2446 state.secure_cookie,
2447 ));
2448 (jar, response).into_response()
2449 }
2450 None => response,
2451 }
2452}
2453
2454async fn recall(
2458 state: &AdminState,
2459 jar: &CookieJar,
2460 ctx: &RequestContext,
2461) -> Option<(
2462 String,
2463 laterite_auth::ResolvedSession,
2464 laterite_auth::RememberCredential,
2465)> {
2466 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
2467 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
2468 let token = recalled.session.token.clone();
2469 let resolved = state.auth.resolve_session(&token).await.ok()?;
2470 Some((token, resolved, recalled.remember))
2471}
2472
2473const MAX_FORM_BYTES: usize = 1024 * 1024;
2476
2477async fn capture_client(
2485 State(state): State<AdminState>,
2486 mut request: Request,
2487 next: Next,
2488) -> Response {
2489 let peer = request
2490 .extensions()
2491 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
2492 .map(|info| info.0);
2493 let headers = request.headers();
2494 let ctx = RequestContext {
2495 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
2496 user_agent: clientip::user_agent(headers),
2497 };
2498 request.extensions_mut().insert(ctx);
2499 next.run(request).await
2500}
2501
2502async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
2503 if !session::is_safe_method(request.method())
2504 && !session::origin_ok(request.headers(), &state.origin)
2505 {
2506 tracing::warn!(
2507 path = request.uri().path(),
2508 "admin CSRF origin check failed"
2509 );
2510 return error::csrf_rejected();
2511 }
2512 next.run(request).await
2513}
2514
2515#[derive(Deserialize)]
2516struct LoginQuery {
2517 ended: Option<String>,
2518}
2519
2520async fn login_form(
2521 State(state): State<AdminState>,
2522 headers: HeaderMap,
2523 Query(query): Query<LoginQuery>,
2524) -> Response {
2525 match state.auth.has_any_operator().await {
2527 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
2528 Ok(true) => {
2529 let i18n = pre_auth_translator(&state, &headers);
2530 let notice = query
2531 .ended
2532 .as_deref()
2533 .and_then(end_notice)
2534 .map(|text| i18n.t(&text));
2535 render(LoginTemplate {
2536 base: state.admin_path.to_string(),
2537 asset_urls: state.asset_urls.clone(),
2538 brand: state.brand().await,
2539 error: None,
2540 notice,
2541 i18n,
2542 })
2543 }
2544 Err(_) => render_error(),
2545 }
2546}
2547
2548#[derive(Deserialize)]
2549struct LoginForm {
2550 username: String,
2551 password: String,
2552 #[serde(default)]
2554 remember: Option<String>,
2555}
2556
2557async fn login_submit(
2558 State(state): State<AdminState>,
2559 jar: CookieJar,
2560 headers: HeaderMap,
2561 Extension(ctx): Extension<RequestContext>,
2562 Form(form): Form<LoginForm>,
2563) -> Response {
2564 match state
2565 .auth
2566 .authenticate(&form.username, &form.password, &ctx)
2567 .await
2568 {
2569 Ok(session) => {
2570 let wants_remember = form.remember.is_some();
2571 let user_id = session.user_id;
2572 let mut jar = jar.add(session_cookie(
2573 session.token,
2574 &state.admin_path,
2575 state.secure_cookie,
2576 ));
2577 if wants_remember {
2578 match state.auth.issue_remember(user_id).await {
2579 Ok(credential) => {
2580 jar = jar.add(remember_cookie(
2581 credential,
2582 &state.admin_path,
2583 state.secure_cookie,
2584 ));
2585 }
2586 Err(e) => {
2588 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
2589 }
2590 }
2591 }
2592 (jar, Redirect::to(&state.admin_path)).into_response()
2593 }
2594 Err(_) => {
2595 let i18n = pre_auth_translator(&state, &headers);
2596 render(LoginTemplate {
2597 base: state.admin_path.to_string(),
2598 asset_urls: state.asset_urls.clone(),
2599 brand: state.brand().await,
2600 error: Some(i18n.t(&t!("Invalid username or password."))),
2601 notice: None,
2602 i18n,
2603 })
2604 }
2605 }
2606}
2607
2608fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
2617 Cookie::build((SESSION_COOKIE, token))
2618 .path(admin_path.to_string())
2619 .http_only(true)
2620 .secure(secure)
2621 .same_site(SameSite::Lax)
2622 .build()
2623}
2624
2625fn remember_cookie(
2628 credential: laterite_auth::RememberCredential,
2629 admin_path: &str,
2630 secure: bool,
2631) -> Cookie<'static> {
2632 let seconds = (credential.expires_at - chrono::Utc::now())
2633 .num_seconds()
2634 .max(0);
2635 Cookie::build((REMEMBER_COOKIE, credential.cookie))
2636 .path(admin_path.to_string())
2637 .http_only(true)
2638 .secure(secure)
2639 .same_site(SameSite::Lax)
2640 .max_age(cookie::time::Duration::seconds(seconds))
2641 .build()
2642}
2643
2644fn signed_out(
2651 state: &AdminState,
2652 jar: CookieJar,
2653 login: &str,
2654 ended: Option<laterite_auth::SessionEnd>,
2655 htmx: bool,
2656) -> Response {
2657 let to = match ended.map(laterite_auth::SessionEnd::code) {
2658 Some(code) => format!("{login}?ended={code}"),
2659 None => login.to_string(),
2660 };
2661 let jar = jar
2662 .remove(
2663 Cookie::build((SESSION_COOKIE, ""))
2664 .path(state.admin_path.to_string())
2665 .build(),
2666 )
2667 .remove(remember_removal(&state.admin_path));
2668 if htmx {
2669 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
2670 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
2671 response.headers_mut().insert("hx-redirect", value);
2672 }
2673 return response;
2674 }
2675 (jar, Redirect::to(&to)).into_response()
2676}
2677
2678fn end_notice(code: &str) -> Option<Text> {
2681 Some(match code {
2682 "password_changed" => {
2683 t!("Your password was changed, so you were signed out. Sign in with the new one.")
2684 }
2685 "deactivated" => t!("This account was deactivated."),
2686 "signed_out_elsewhere" => t!("You were signed out from another device."),
2687 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
2688 _ => return None,
2689 })
2690}
2691
2692fn remember_removal(admin_path: &str) -> Cookie<'static> {
2694 Cookie::build((REMEMBER_COOKIE, ""))
2695 .path(admin_path.to_string())
2696 .build()
2697}
2698
2699#[derive(Deserialize)]
2700struct SetupForm {
2701 username: String,
2702 first_name: String,
2703 last_name: String,
2704 email: String,
2705 password: String,
2706 timezone: String,
2707}
2708
2709async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2712 match state.auth.has_any_operator().await {
2713 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2714 Ok(false) => render(setup_view(
2715 &state.admin_path,
2716 state.brand().await,
2717 state.timezone,
2718 None,
2719 pre_auth_translator(&state, &headers),
2720 state.asset_urls.clone(),
2721 state.auth.password_policy().min_length,
2722 )),
2723 Err(_) => render_error(),
2724 }
2725}
2726
2727async fn setup_submit(
2728 State(state): State<AdminState>,
2729 jar: CookieJar,
2730 headers: HeaderMap,
2731 Form(form): Form<SetupForm>,
2732) -> Response {
2733 match state.auth.has_any_operator().await {
2735 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2736 Ok(false) => {}
2737 Err(_) => return render_error(),
2738 }
2739
2740 let username = form.username.trim();
2741 let email = form.email.trim();
2742 let first_name = form.first_name.trim();
2743 let last_name = form.last_name.trim();
2744 let tz = form.timezone.trim();
2745 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
2746 {
2747 return render(setup_view(
2748 &state.admin_path,
2749 state.brand().await,
2750 state.timezone,
2751 Some(t!(
2752 "Username, first name, email, and password are all required."
2753 )),
2754 pre_auth_translator(&state, &headers),
2755 state.asset_urls.clone(),
2756 state.auth.password_policy().min_length,
2757 ));
2758 }
2759 if tz.parse::<Tz>().is_err() {
2761 return render(setup_view(
2762 &state.admin_path,
2763 state.brand().await,
2764 state.timezone,
2765 Some(t!("That is not a recognised timezone.")),
2766 pre_auth_translator(&state, &headers),
2767 state.asset_urls.clone(),
2768 state.auth.password_policy().min_length,
2769 ));
2770 }
2771
2772 let min_length = state.auth.password_policy().min_length;
2773 if form.password.chars().count() < min_length {
2774 return render(setup_view(
2775 &state.admin_path,
2776 state.brand().await,
2777 state.timezone,
2778 Some(t!(
2779 "A password needs at least {n} characters.",
2780 n = min_length as i64
2781 )),
2782 pre_auth_translator(&state, &headers),
2783 state.asset_urls.clone(),
2784 state.auth.password_policy().min_length,
2785 ));
2786 }
2787 let new = NewOperator {
2788 username,
2789 email,
2790 first_name,
2791 last_name: (!last_name.is_empty()).then_some(last_name),
2792 password: &form.password,
2793 timezone: Some(tz),
2794 };
2795 let id = match state.auth.create_superuser(new).await {
2796 Ok(id) => id,
2797 Err(_) => {
2798 return render(setup_view(
2799 &state.admin_path,
2800 state.brand().await,
2801 state.timezone,
2802 Some(t!(
2803 "Could not create the account. The username or email may already be taken."
2804 )),
2805 pre_auth_translator(&state, &headers),
2806 state.asset_urls.clone(),
2807 state.auth.password_policy().min_length,
2808 ));
2809 }
2810 };
2811 let actor = laterite_core::Actor::system("first-run setup");
2814 let target = id.to_string();
2815 let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
2816 "backend_user",
2817 &target,
2818 Some(username),
2819 );
2820 if let Err(e) = state.auth.record_audit(entry).await {
2821 tracing::error!(error = %e, "failed to write audit log entry");
2822 }
2823
2824 match state
2826 .auth
2827 .authenticate(username, &form.password, &RequestContext::default())
2828 .await
2829 {
2830 Ok(session) => {
2831 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
2832 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
2833 }
2834 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2835 }
2836}
2837
2838fn setup_view(
2841 admin_path: &str,
2842 brand: String,
2843 default_tz: Tz,
2844 error: Option<Text>,
2845 i18n: Translator,
2846 asset_urls: Arc<AssetUrls>,
2847 min_password: usize,
2848) -> SetupTemplate {
2849 let default_name = default_tz.name();
2850 let zones = TZ_VARIANTS
2851 .iter()
2852 .filter(|tz| zone_offered(tz.name()))
2853 .map(|tz| TzOption {
2854 name: tz.name().to_string(),
2855 selected: tz.name() == default_name,
2856 })
2857 .collect();
2858 SetupTemplate {
2859 base: admin_path.to_string(),
2860 asset_urls,
2861 brand,
2862 zones,
2863 error: error.map(|e| i18n.t(&e)),
2864 min_password: min_password as i64,
2865 i18n,
2866 }
2867}
2868
2869async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
2870 if let Some(cookie) = jar.get(SESSION_COOKIE) {
2871 let _ = state.auth.logout(cookie.value()).await;
2872 }
2873 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2876 let _ = state.auth.revoke_remember(cookie.value()).await;
2877 }
2878 let removal = Cookie::build((SESSION_COOKIE, ""))
2879 .path(state.admin_path.to_string())
2880 .build();
2881 (
2882 jar.remove(removal)
2883 .remove(remember_removal(&state.admin_path)),
2884 Redirect::to(&format!("{}/login", state.admin_path)),
2885 )
2886 .into_response()
2887}
2888
2889async fn dashboard(
2890 Extension(shell): Extension<Shell>,
2891 Extension(user): Extension<AuthenticatedUser>,
2892) -> Response {
2893 render(DashboardTemplate {
2894 username: user.user.username,
2895 shell,
2896 })
2897}
2898
2899fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
2902 match &item.permission {
2903 None => true,
2904 Some(p) => perms.allows(p),
2905 }
2906}
2907
2908fn visible_settings(
2912 items: &[settings::SettingsItem],
2913 perms: &PermissionSet,
2914) -> Vec<settings::SettingsItem> {
2915 items
2916 .iter()
2917 .filter(|item| operator_can_see(item, perms))
2918 .cloned()
2919 .collect()
2920}
2921
2922async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
2923 settings::index(shell)
2924}
2925
2926async fn settings_edit(
2927 state: AdminState,
2928 shell: Shell,
2929 user: AuthenticatedUser,
2930 code: String,
2931) -> Response {
2932 let items = visible_settings(&state.settings, &user.permissions);
2935 match items
2936 .iter()
2937 .find(|item| item.code == code && item.link.is_none())
2938 {
2939 Some(item) => settings::edit_form(&state, item, shell).await,
2940 None => not_found(),
2941 }
2942}
2943
2944async fn settings_update(
2945 state: AdminState,
2946 shell: Shell,
2947 user: AuthenticatedUser,
2948 session: session::SessionHandle,
2949 code: String,
2950 data: HashMap<String, String>,
2951) -> Response {
2952 let items = visible_settings(&state.settings, &user.permissions);
2953 match items
2954 .iter()
2955 .find(|item| item.code == code && item.link.is_none())
2956 {
2957 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
2958 None => not_found(),
2959 }
2960}
2961
2962async fn preferences_form(
2964 State(state): State<AdminState>,
2965 Extension(shell): Extension<Shell>,
2966 Extension(user): Extension<AuthenticatedUser>,
2967 jar: CookieJar,
2968) -> Response {
2969 let account = account_security(&state, &user, &shell, &jar).await;
2970 render(preferences_view(
2971 &shell,
2972 &user,
2973 state.timezone,
2974 &state.default_locale,
2975 &offered_locales(&state.catalogs),
2976 account,
2977 state.auth.password_policy().min_length,
2978 ))
2979}
2980
2981#[derive(Deserialize)]
2982struct RevokeSessionForm {
2983 id: String,
2984}
2985
2986async fn session_revoke(
2988 State(state): State<AdminState>,
2989 Extension(user): Extension<AuthenticatedUser>,
2990 Extension(session): Extension<session::SessionHandle>,
2991 Form(form): Form<RevokeSessionForm>,
2992) -> Response {
2993 match state.auth.revoke_session(user.user.id, &form.id).await {
2994 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
2995 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
2996 Err(e) => {
2997 tracing::error!(error = %e, "revoking a session failed");
2998 session.push_flash(
2999 FlashLevel::Error,
3000 t!("That session could not be signed out."),
3001 );
3002 }
3003 }
3004 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
3005}
3006
3007async fn session_revoke_others(
3009 State(state): State<AdminState>,
3010 Extension(user): Extension<AuthenticatedUser>,
3011 Extension(session): Extension<session::SessionHandle>,
3012 jar: CookieJar,
3013) -> Response {
3014 let token = jar
3015 .get(SESSION_COOKIE)
3016 .map(|c| c.value().to_string())
3017 .unwrap_or_default();
3018 match state.auth.sign_out_everywhere(user.user.id, &token).await {
3019 Ok(_) => session.push_flash(
3020 FlashLevel::Success,
3021 t!("Every other session was signed out."),
3022 ),
3023 Err(e) => {
3024 tracing::error!(error = %e, "signing out other sessions failed");
3025 session.push_flash(
3026 FlashLevel::Error,
3027 t!("Those sessions could not be signed out."),
3028 );
3029 }
3030 }
3031 (
3034 jar.remove(remember_removal(&state.admin_path)),
3035 Redirect::to(&format!("{}/preferences", state.admin_path)),
3036 )
3037 .into_response()
3038}
3039
3040#[derive(Deserialize)]
3041struct PasswordForm {
3042 current_password: String,
3043 new_password: String,
3044 confirm_password: String,
3045}
3046
3047async fn password_update(
3052 State(state): State<AdminState>,
3053 Extension(user): Extension<AuthenticatedUser>,
3054 Extension(session): Extension<session::SessionHandle>,
3055 Extension(ctx): Extension<RequestContext>,
3056 jar: CookieJar,
3057 Form(form): Form<PasswordForm>,
3058) -> Response {
3059 let back = format!("{}/preferences#password", state.admin_path);
3060 let refuse = |message: Text| {
3061 session.push_flash(FlashLevel::Error, message);
3062 Redirect::to(&back).into_response()
3063 };
3064 if form.new_password != form.confirm_password {
3065 return refuse(t!("The new passwords do not match."));
3066 }
3067 let min_length = state.auth.password_policy().min_length;
3068 if form.new_password.chars().count() < min_length {
3069 return refuse(t!(
3070 "A password needs at least {n} characters.",
3071 n = min_length as i64
3072 ));
3073 }
3074 let remembered_here = match jar.get(REMEMBER_COOKIE) {
3077 Some(cookie) => {
3078 state
3079 .auth
3080 .remember_holder(cookie.value())
3081 .await
3082 .ok()
3083 .flatten()
3084 == Some(user.user.id)
3085 }
3086 None => false,
3087 };
3088 let token = jar
3089 .get(SESSION_COOKIE)
3090 .map(|c| c.value().to_string())
3091 .unwrap_or_default();
3092 let changed = state
3093 .auth
3094 .change_own_password(
3095 user.user.id,
3096 &form.current_password,
3097 &form.new_password,
3098 &token,
3099 &ctx,
3100 )
3101 .await;
3102 match changed {
3103 Ok(()) => {}
3104 Err(laterite_auth::AuthError::InvalidCredentials) => {
3105 return refuse(t!("Your current password is not correct."));
3106 }
3107 Err(laterite_auth::AuthError::TooManyAttempts) => {
3108 return refuse(t!("Too many attempts. Try again later."));
3109 }
3110 Err(e) => {
3111 tracing::error!(error = %e, "changing a password failed");
3112 return refuse(t!("The password could not be changed."));
3113 }
3114 }
3115 session.push_flash_sticky(
3116 FlashLevel::Success,
3117 t!("Password changed. Every other device was signed out."),
3118 );
3119 let jar = if remembered_here {
3120 match state.auth.issue_remember(user.user.id).await {
3121 Ok(credential) => jar.add(remember_cookie(
3122 credential,
3123 &state.admin_path,
3124 state.secure_cookie,
3125 )),
3126 Err(e) => {
3127 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
3128 jar.remove(remember_removal(&state.admin_path))
3129 }
3130 }
3131 } else if jar.get(REMEMBER_COOKIE).is_some() {
3132 jar.remove(remember_removal(&state.admin_path))
3135 } else {
3136 jar
3137 };
3138 (
3139 jar,
3140 Redirect::to(&format!("{}/preferences", state.admin_path)),
3141 )
3142 .into_response()
3143}
3144
3145#[derive(Deserialize)]
3146struct PreferencesForm {
3147 timezone: String,
3148 #[serde(default)]
3150 locale: String,
3151}
3152
3153async fn preferences_update(
3154 State(state): State<AdminState>,
3155 Extension(user): Extension<AuthenticatedUser>,
3156 Extension(session): Extension<session::SessionHandle>,
3157 Form(form): Form<PreferencesForm>,
3158) -> Response {
3159 let back = format!("{}/preferences", state.admin_path);
3160 let offered = offered_locales(&state.catalogs);
3161 let tz = form.timezone.trim();
3163 let tz_stored = if tz.is_empty() {
3164 None
3165 } else if tz.parse::<Tz>().is_ok() {
3166 Some(tz)
3167 } else {
3168 session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
3169 return Redirect::to(&back).into_response();
3170 };
3171 let loc = form.locale.trim();
3172 let loc_stored = if loc.is_empty() {
3173 None
3174 } else if offered.iter().any(|l| l == loc) {
3175 Some(loc)
3176 } else {
3177 session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
3178 return Redirect::to(&back).into_response();
3179 };
3180 if state
3181 .auth
3182 .set_user_timezone(user.user.id, tz_stored)
3183 .await
3184 .is_err()
3185 {
3186 return render_error();
3187 }
3188 match state.auth.set_user_locale(user.user.id, loc_stored).await {
3189 Ok(()) => {
3190 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
3191 Redirect::to(&back).into_response()
3192 }
3193 Err(_) => render_error(),
3194 }
3195}
3196
3197fn preferences_view(
3201 shell: &Shell,
3202 user: &AuthenticatedUser,
3203 default_tz: Tz,
3204 default_locale: &str,
3205 offered: &[String],
3206 account: AccountSecurity,
3207 min_password: usize,
3208) -> PreferencesTemplate {
3209 let current = user.user.timezone.as_deref();
3210 let zones = TZ_VARIANTS
3211 .iter()
3212 .filter(|tz| zone_offered(tz.name()))
3213 .map(|tz| TzOption {
3214 name: tz.name().to_string(),
3215 selected: current == Some(tz.name()),
3216 })
3217 .collect();
3218 let current_locale = user.user.locale.as_deref();
3219 let locales = offered
3220 .iter()
3221 .map(|code| LocaleOption {
3222 name: locale_name(code),
3223 selected: current_locale == Some(code.as_str()),
3224 code: code.clone(),
3225 })
3226 .collect();
3227 PreferencesTemplate {
3228 shell: shell.clone(),
3229 zones,
3230 effective_tz: shell.tz.name().to_string(),
3231 default_tz: default_tz.name().to_string(),
3232 inherits: current.is_none(),
3233 locales,
3234 default_locale: locale_name(default_locale),
3235 inherits_locale: current_locale.is_none(),
3236 sessions: account.sessions,
3237 password_changed: account.password_changed,
3238 must_change: account.must_change,
3239 min_password: min_password as i64,
3240 }
3241}
3242
3243struct AccountSecurity {
3246 sessions: Vec<SessionRow>,
3247 password_changed: Option<String>,
3248 must_change: bool,
3250}
3251
3252async fn account_security(
3253 state: &AdminState,
3254 user: &AuthenticatedUser,
3255 shell: &Shell,
3256 jar: &CookieJar,
3257) -> AccountSecurity {
3258 let locale = list::date_locale(shell.locale());
3259 let password_changed = match state.auth.password_changed_at(user.user.id).await {
3260 Ok(at) => {
3261 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
3262 }
3263 Err(e) => {
3264 tracing::error!(error = %e, "reading the password change time failed");
3265 None
3266 }
3267 };
3268 AccountSecurity {
3269 sessions: session_rows(state, user, shell, jar).await,
3270 password_changed,
3271 must_change: state
3272 .auth
3273 .must_change_password(user.user.id)
3274 .await
3275 .unwrap_or(false),
3276 }
3277}
3278
3279async fn session_rows(
3282 state: &AdminState,
3283 user: &AuthenticatedUser,
3284 shell: &Shell,
3285 jar: &CookieJar,
3286) -> Vec<SessionRow> {
3287 let token = jar
3288 .get(SESSION_COOKIE)
3289 .map(|c| c.value().to_string())
3290 .unwrap_or_default();
3291 let locale = list::date_locale(shell.locale());
3292 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
3293 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
3294 };
3295 match state.auth.list_sessions(user.user.id, &token).await {
3296 Ok(sessions) => sessions
3297 .into_iter()
3298 .map(|s| SessionRow {
3299 id: s.id,
3300 started: stamp(s.created_at),
3301 last_seen: stamp(s.last_seen_at),
3302 expires: stamp(s.expires_at),
3303 device: clientip::describe(s.user_agent.as_deref())
3304 .or(s.user_agent)
3305 .unwrap_or_else(|| "-".to_string()),
3306 ip_address: s.ip_address.unwrap_or_default(),
3307 current: s.current,
3308 })
3309 .collect(),
3310 Err(e) => {
3311 tracing::error!(error = %e, "listing sessions failed");
3312 Vec::new()
3313 }
3314 }
3315}
3316
3317fn builtin_resources() -> Vec<Resource> {
3319 vec![
3320 Resource {
3321 base_path: "/users".to_string(),
3322 nav_label: "Backend Users".into(),
3323 list: backend_users_list_config(),
3324 form: None,
3325 permission: Some("backend.manage_users".to_string()),
3326 import: None,
3327 },
3328 Resource {
3329 base_path: "/roles".to_string(),
3330 nav_label: "Roles".into(),
3331 list: roles_list_config(),
3332 form: None,
3335 permission: Some("backend.manage_roles".to_string()),
3336 import: None,
3337 },
3338 resource!("descriptors/audit-log.yaml"),
3339 resource!("descriptors/access-log.yaml"),
3340 ]
3341}
3342
3343fn builtin_settings() -> Vec<settings::SettingsItem> {
3347 vec![
3348 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
3349 .description("Manage backend administrator accounts.")
3350 .category("Users")
3351 .order(10)
3352 .icon("users")
3353 .permission("backend.manage_users")
3354 .link("/users"),
3355 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
3356 .description("Manage roles and their permissions.")
3357 .category("Users")
3358 .order(20)
3359 .icon("shield")
3360 .permission("backend.manage_roles")
3361 .link("/roles"),
3362 settings::brand::settings_item(),
3363 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
3364 .description("Enable or disable installed plugins.")
3365 .category("System")
3366 .order(10)
3367 .icon("plug")
3368 .permission(plugins::MANAGE_PERMISSION)
3369 .link("/plugins"),
3370 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
3371 .description("Review the record of administrative changes.")
3372 .category("System")
3373 .order(20)
3374 .icon("history")
3375 .permission("backend.view_audit_log")
3376 .link("/audit-log"),
3377 settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
3378 .description("Review sign-ins, failures and lockouts.")
3379 .category("System")
3380 .order(30)
3381 .icon("log-in")
3382 .permission("backend.view_access_log")
3383 .link("/access-log"),
3384 ]
3385}
3386
3387fn backend_users_list_config() -> list::ListConfig {
3388 list::ListConfig {
3389 entity: "backend_users".to_string(),
3390 title: "Backend Users".into(),
3391 columns: vec![
3392 list::ListColumn::new("username", "Username"),
3393 list::ListColumn::new("email", "Email"),
3394 list::ListColumn::new("first_name", "First name"),
3395 list::ListColumn::new("last_name", "Last name"),
3396 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
3397 list::ListColumn::new("is_active", "Active").yes_no(),
3398 list::ListColumn::new("created_at", "Created").datetime(),
3399 ],
3400 order_by: "created_at".to_string(),
3401 creatable: true,
3404 edit_base: Some("/users".to_string()),
3405 filters: vec![
3406 list::ListFilter::boolean("is_active", "Active"),
3407 list::ListFilter::boolean("is_superuser", "Superuser"),
3408 ],
3409 ..Default::default()
3413 }
3414}
3415
3416fn roles_list_config() -> list::ListConfig {
3417 list::ListConfig {
3418 entity: "backend_roles".to_string(),
3419 title: "Roles".into(),
3420 columns: vec![
3421 list::ListColumn::new("code", "Code"),
3422 list::ListColumn::new("name", "Name"),
3423 list::ListColumn::new("created_at", "Created").datetime(),
3424 ],
3425 order_by: "created_at".to_string(),
3426 edit_base: Some("/roles".to_string()),
3427 creatable: true,
3428 deletable: true,
3429 ..Default::default()
3430 }
3431}
3432
3433#[derive(Template)]
3434#[template(path = "login.html")]
3435struct LoginTemplate {
3436 base: String,
3438 asset_urls: Arc<AssetUrls>,
3440 brand: String,
3441 error: Option<String>,
3442 notice: Option<String>,
3444 i18n: Translator,
3447}
3448
3449fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
3452 match urls.get(key) {
3453 Some(path) => format!("{base}/assets/{path}"),
3454 None => {
3455 debug_assert!(false, "asset key `{key}` is not registered");
3456 format!("{base}/assets/{key}")
3457 }
3458 }
3459}
3460
3461impl LoginTemplate {
3462 fn asset(&self, key: &str) -> String {
3463 preauth_asset(&self.asset_urls, &self.base, key)
3464 }
3465 fn t(&self, source: &str) -> String {
3466 self.i18n.t(&Text::dynamic(source))
3467 }
3468 fn locale(&self) -> &str {
3469 self.i18n.locale()
3470 }
3471}
3472
3473#[derive(Template)]
3474#[template(path = "dashboard.html")]
3475struct DashboardTemplate {
3476 shell: Shell,
3477 username: String,
3478}
3479
3480#[derive(Template)]
3481#[template(path = "setup.html")]
3482struct SetupTemplate {
3483 base: String,
3485 asset_urls: Arc<AssetUrls>,
3487 brand: String,
3488 zones: Vec<TzOption>,
3489 error: Option<String>,
3490 i18n: Translator,
3492 min_password: i64,
3494}
3495
3496impl SetupTemplate {
3497 fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
3500 let mut text = Text::dynamic(source);
3501 for (name, value) in args {
3502 text = text.arg(*name, *value);
3503 }
3504 self.i18n.t(&text)
3505 }
3506
3507 fn asset(&self, key: &str) -> String {
3508 preauth_asset(&self.asset_urls, &self.base, key)
3509 }
3510 fn t(&self, source: &str) -> String {
3511 self.i18n.t(&Text::dynamic(source))
3512 }
3513 fn locale(&self) -> &str {
3514 self.i18n.locale()
3515 }
3516}
3517
3518#[derive(Template)]
3519#[template(path = "preferences.html")]
3520struct PreferencesTemplate {
3521 shell: Shell,
3522 zones: Vec<TzOption>,
3523 effective_tz: String,
3525 default_tz: String,
3527 inherits: bool,
3529 locales: Vec<LocaleOption>,
3530 default_locale: String,
3532 inherits_locale: bool,
3534 sessions: Vec<SessionRow>,
3536 password_changed: Option<String>,
3538 must_change: bool,
3540 min_password: i64,
3542}
3543
3544struct SessionRow {
3546 id: String,
3547 started: String,
3548 last_seen: String,
3549 expires: String,
3550 device: String,
3553 ip_address: String,
3555 current: bool,
3556}
3557
3558struct TzOption {
3559 name: String,
3560 selected: bool,
3561}
3562
3563struct LocaleOption {
3564 code: String,
3566 name: String,
3568 selected: bool,
3569}
3570
3571#[derive(Clone)]
3572struct NavView {
3573 label: String,
3574 path: String,
3575 active: bool,
3576 icon: String,
3579}
3580
3581pub(crate) fn render<T: Template>(template: T) -> Response {
3583 match template.render() {
3584 Ok(html) => Html(html).into_response(),
3585 Err(e) => AdminError::from(e).into_response(),
3587 }
3588}
3589
3590pub(crate) fn render_error() -> Response {
3591 error::masked_500()
3592}
3593
3594pub(crate) fn not_found() -> Response {
3595 AdminError::NotFound.into_response()
3596}
3597
3598pub(crate) fn forbidden() -> Response {
3599 AdminError::Forbidden.into_response()
3600}
3601
3602#[cfg(test)]
3603mod tests {
3604 use super::*;
3605
3606 fn write_form() -> form::FormConfig {
3607 form::FormConfig {
3608 enter: Default::default(),
3609 confirm_leave: true,
3610 focus: Default::default(),
3611 entity: "widgets".to_string(),
3612 title: "Widgets".into(),
3613 base_path: "/widgets".to_string(),
3614 id_field: "id".to_string(),
3615 fields: vec![],
3616 persist: None,
3617 timestamps: false,
3618 }
3619 }
3620
3621 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
3622 Resource {
3623 base_path: "/widgets".to_string(),
3624 nav_label: "Widgets".into(),
3625 list: backend_users_list_config(),
3626 form,
3627 permission: permission.map(str::to_string),
3628 import: None,
3629 }
3630 }
3631
3632 #[test]
3633 #[should_panic(expected = "must declare a permission")]
3634 fn write_resource_without_permission_aborts_boot() {
3635 let resource = resource_with(Some(write_form()), None);
3638 let _ = mount_resource(
3639 &resource,
3640 &field::builtin_registry(),
3641 &list::builtin_column_registry(),
3642 &std::collections::HashSet::new(),
3643 &persist::PersisterRegistry::new(),
3644 &[],
3645 );
3646 }
3647
3648 #[test]
3650 fn the_access_log_is_read_from_its_descriptor_file() {
3651 let access = builtin_resources()
3652 .into_iter()
3653 .find(|r| r.base_path == "/access-log")
3654 .expect("the access log is registered");
3655 assert_eq!(access.list.entity, "backend_access_log");
3656 assert_eq!(
3657 access.permission.as_deref(),
3658 Some("backend.view_access_log")
3659 );
3660 assert!(access.form.is_none(), "append-only");
3661 assert_eq!(
3662 access
3663 .list
3664 .columns
3665 .iter()
3666 .map(|c| c.field.as_str())
3667 .collect::<Vec<_>>(),
3668 [
3669 "created_at",
3670 "username_attempted",
3671 "event",
3672 "ip_address",
3673 "user_agent"
3674 ]
3675 );
3676 }
3677
3678 #[test]
3681 fn the_audit_log_is_read_from_its_descriptor_file() {
3682 let audit = builtin_resources()
3683 .into_iter()
3684 .find(|r| r.base_path == "/audit-log")
3685 .expect("the audit log is registered");
3686 assert_eq!(audit.list.entity, "backend_audit_log");
3687 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
3688 assert_eq!(
3689 audit
3690 .list
3691 .columns
3692 .iter()
3693 .map(|c| c.field.as_str())
3694 .collect::<Vec<_>>(),
3695 [
3696 "created_at",
3697 "actor_username",
3698 "action",
3699 "target_type",
3700 "target_id",
3701 "target_label"
3702 ],
3703 "in the order the file writes them"
3704 );
3705 assert_eq!(audit.list.columns[0].column_type, "datetime");
3706 assert_eq!(audit.list.per_page, 50);
3707 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
3708 assert!(audit.list.exportable, "evidence may leave the panel");
3709 assert!(audit.form.is_none(), "append-only: no form");
3710 assert!(!audit.list.deletable, "and nothing removes from it");
3711 }
3712
3713 #[test]
3714 #[should_panic(expected = "uses unregistered type `nope`")]
3715 fn unregistered_column_type_aborts_boot() {
3716 let mut resource = resource_with(None, None);
3717 let mut score = list::ListColumn::new("score", "Score");
3718 score.column_type = "nope".into();
3719 resource.list.columns.push(score);
3720 let _ = mount_resource(
3721 &resource,
3722 &field::builtin_registry(),
3723 &list::builtin_column_registry(),
3724 &std::collections::HashSet::new(),
3725 &persist::PersisterRegistry::new(),
3726 &[],
3727 );
3728 }
3729
3730 #[test]
3731 fn read_only_resource_may_omit_permission() {
3732 let resource = resource_with(None, None);
3734 let _ = mount_resource(
3735 &resource,
3736 &field::builtin_registry(),
3737 &list::builtin_column_registry(),
3738 &std::collections::HashSet::new(),
3739 &persist::PersisterRegistry::new(),
3740 &[],
3741 );
3742 }
3743
3744 #[test]
3745 fn shell_localizes_through_the_translator_and_falls_back() {
3746 let shell = Shell::test();
3748 assert_eq!(shell.t("Save"), "Save");
3749 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
3750 }
3751
3752 #[test]
3753 fn shell_tf_interpolates_integer_arguments() {
3754 let shell = Shell::test();
3755 assert_eq!(
3756 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
3757 "Page 2 of 5"
3758 );
3759 }
3760
3761 #[test]
3762 fn display_tz_prefers_a_valid_operator_preference() {
3763 assert_eq!(
3764 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
3765 Tz::Asia__Kolkata
3766 );
3767 }
3768
3769 #[test]
3770 fn display_tz_falls_back_when_unset_or_invalid() {
3771 let default = Tz::Europe__London;
3772 assert_eq!(resolve_display_tz(None, default), default);
3774 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
3776 assert_eq!(resolve_display_tz(Some(""), default), default);
3777 }
3778
3779 #[test]
3780 fn accept_language_orders_by_weight_and_drops_wildcard() {
3781 assert_eq!(
3782 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
3783 vec!["en-US", "en", "kn"]
3784 );
3785 assert_eq!(
3787 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
3788 vec!["kn", "en"]
3789 );
3790 }
3791
3792 #[test]
3793 fn locale_chain_prefers_operator_then_header_then_default() {
3794 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
3796 assert_eq!(
3799 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
3800 vec!["kn", "ta", "hi", "en"]
3801 );
3802 assert_eq!(
3804 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
3805 vec!["kn", "en", "ta"]
3806 );
3807 assert_eq!(
3809 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
3810 vec!["kn", "en"]
3811 );
3812 }
3813
3814 #[test]
3815 fn locale_chain_skips_unserveable_and_defaults_to_en() {
3816 let loaded = ["hi".to_string()];
3818 assert_eq!(
3821 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
3822 vec!["en"]
3823 );
3824 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
3826 assert_eq!(
3828 resolve_locale_chain(Some("hi"), None, "en", &loaded),
3829 vec!["hi", "en"]
3830 );
3831 assert_eq!(
3833 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
3834 vec![laterite_core::PSEUDO_LOCALE, "en"]
3835 );
3836 }
3837
3838 #[test]
3839 fn page_assets_dedupe_preserve_order_and_classify() {
3840 let mut reg = AssetRegistry::new();
3841 reg.insert(
3842 "a.js",
3843 AdminAsset {
3844 mime: "text/javascript; charset=utf-8",
3845 bytes: std::borrow::Cow::Borrowed(b"//"),
3846 },
3847 );
3848 reg.insert(
3849 "b.css",
3850 AdminAsset {
3851 mime: "text/css; charset=utf-8",
3852 bytes: std::borrow::Cow::Borrowed(b"body{}"),
3853 },
3854 );
3855 let urls = asset_urls(®);
3858 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
3859 assert_eq!(assets.len(), 2);
3860 assert_eq!(
3861 assets[0].url,
3862 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
3863 );
3864 assert!(!assets[0].css);
3865 assert_eq!(
3866 assets[1].url,
3867 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
3868 );
3869 assert!(assets[1].css);
3870 assert_ne!(assets[0].url, assets[1].url);
3873 }
3874
3875 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
3879 laterite_core::testing::connect_test(&[]).await
3880 }
3881
3882 #[tokio::test]
3883 async fn builtin_migrations_create_the_admin_tables() {
3884 let (db, _guard) = empty_db().await;
3885 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
3886 .await
3887 .unwrap();
3888 for table in ["backend_users", "settings"] {
3892 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
3893 .fetch_optional(&db.pool)
3894 .await;
3895 assert!(
3896 probe.is_ok(),
3897 "{table} should exist after builtin_migrations"
3898 );
3899 }
3900 }
3901
3902 #[tokio::test]
3903 async fn brand_setting_overrides_config_and_blank_falls_back() {
3904 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
3905 let state = AdminState {
3906 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3907 db: db.clone(),
3908 nav: Arc::new(Vec::new()),
3909 settings: Arc::new(Vec::new()),
3910 permissions: Arc::new(builtin_permissions()),
3911 trusted_proxies: Arc::new(Vec::new()),
3912 icons: Arc::new(laterite_core::icons::IconSet::new()),
3913 sprite_url: Arc::from(""),
3914 admin_path: Arc::from("/admin"),
3915 secure_cookie: false,
3916 origin: Arc::from(""),
3917 timezone: Tz::UTC,
3918 default_locale: Arc::from("en"),
3919 catalogs: Arc::new(CatalogStore::default()),
3920 app_name: "Configured Name".to_string(),
3921 brand_cache: Arc::new(RwLock::new(None)),
3922 field_types: Arc::new(field::builtin_registry()),
3923 column_types: Arc::new(list::builtin_column_registry()),
3924 plugin_defined: Arc::new(laterite_core::Registry::new()),
3925 assets: Arc::new(builtin_assets()),
3926 asset_urls: Arc::new(asset_urls(&builtin_assets())),
3927 pickers: Arc::new(picker::PickerRegistry::new()),
3928 overrides: Arc::new(field::NoOverrides),
3929 };
3930
3931 assert_eq!(state.brand().await, "Configured Name");
3933
3934 settings::store::save(
3937 &db,
3938 &settings::BrandSetting {
3939 app_name: "Acme Corp".to_string(),
3940 },
3941 )
3942 .await
3943 .unwrap();
3944 state.invalidate_brand();
3945 assert_eq!(state.brand().await, "Acme Corp");
3946
3947 settings::store::save(
3949 &db,
3950 &settings::BrandSetting {
3951 app_name: " ".to_string(),
3952 },
3953 )
3954 .await
3955 .unwrap();
3956 state.invalidate_brand();
3957 assert_eq!(state.brand().await, "Configured Name");
3958 }
3959
3960 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
3961 settings::SettingsItem {
3962 code: code.to_string(),
3963 label: code.into(),
3964 description: String::new().into(),
3965 category: "General".into(),
3966 hint: None,
3967 confirm_leave: true,
3968 order: 1,
3969 icon: None,
3970 permission: permission.map(str::to_string),
3971 link: None,
3972 fields: Vec::new(),
3973 route: None,
3974 }
3975 }
3976
3977 #[test]
3978 fn settings_visibility_respects_permissions() {
3979 let items = vec![
3980 settings_item("public", None),
3981 settings_item("gated", Some("backend.manage_users")),
3982 ];
3983
3984 let none = PermissionSet::new(false, Vec::<String>::new());
3986 let codes: Vec<String> = visible_settings(&items, &none)
3987 .into_iter()
3988 .map(|i| i.code)
3989 .collect();
3990 assert_eq!(codes, ["public"]);
3991
3992 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
3994 assert_eq!(visible_settings(&items, &granted).len(), 2);
3995
3996 let superuser = PermissionSet::new(true, Vec::<String>::new());
3998 assert_eq!(visible_settings(&items, &superuser).len(), 2);
3999 }
4000
4001 #[test]
4002 fn context_sidebar_follows_settings_links() {
4003 let items = builtin_settings();
4004 let superuser = PermissionSet::new(true, Vec::<String>::new());
4005 let tr = Translator::new("en");
4006 let sidebar = |path: &str| {
4007 resolve_nav_context(
4008 &[],
4009 &items,
4010 "/admin",
4011 &superuser,
4012 path,
4013 &tr,
4014 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
4015 )
4016 .0
4017 };
4018 let active_path = |path: &str| -> Option<String> {
4019 sidebar(path)
4020 .into_iter()
4021 .flat_map(|g| g.items)
4022 .find(|i| i.active)
4023 .map(|i| i.path)
4024 };
4025
4026 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
4028 assert_eq!(
4029 active_path("/admin/roles/42/edit").as_deref(),
4030 Some("/admin/roles")
4031 );
4032 assert!(!sidebar("/admin/settings").is_empty());
4034 assert_eq!(active_path("/admin/settings"), None);
4035 assert_eq!(
4040 active_path("/admin/settings/branding").as_deref(),
4041 Some("/admin/settings/branding")
4042 );
4043 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
4046 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
4047 assert!(sidebar("/admin").is_empty());
4049 }
4050
4051 #[test]
4052 fn active_nav_lights_the_right_tab() {
4053 let nav = vec![
4054 NavLink {
4055 label: "Dashboard".into(),
4056 path: "/admin".to_string(),
4057 icon: Some("layout-dashboard"),
4058 },
4059 NavLink {
4060 label: "Pages".into(),
4061 path: "/admin/pages".to_string(),
4062 icon: None,
4063 },
4064 NavLink {
4065 label: "Settings".into(),
4066 path: "/admin/settings".to_string(),
4067 icon: Some("settings"),
4068 },
4069 ];
4070
4071 assert_eq!(
4073 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
4074 Some("/admin")
4075 );
4076 assert_eq!(
4078 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
4079 Some("/admin/pages")
4080 );
4081 assert_eq!(
4083 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
4084 None
4085 );
4086 assert_eq!(
4089 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
4090 None
4091 );
4092 assert_eq!(
4095 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
4096 Some("/admin/settings")
4097 );
4098 assert_eq!(
4099 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
4100 Some("/admin/settings")
4101 );
4102 }
4103}
4104
4105#[cfg(test)]
4106mod session_cookie_tests {
4107 use super::*;
4108
4109 #[test]
4113 fn the_session_cookie_never_outlives_the_browser() {
4114 let cookie = session_cookie("tok".into(), "/admin", false);
4115 assert_eq!(cookie.max_age(), None);
4116 assert!(cookie.http_only().unwrap_or(false));
4117 assert_eq!(cookie.path(), Some("/admin"));
4118 }
4119
4120 #[test]
4123 fn the_remember_cookie_carries_its_own_lifetime() {
4124 let credential = laterite_auth::RememberCredential {
4125 cookie: "sel:ver".into(),
4126 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
4127 };
4128 let cookie = remember_cookie(credential, "/admin", true);
4129 let age = cookie.max_age().expect("a remember cookie must persist");
4130 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
4132 assert!(cookie.http_only().unwrap_or(false));
4133 assert!(cookie.secure().unwrap_or(false));
4134 }
4135
4136 #[test]
4138 fn the_removal_matches_the_cookie_it_clears() {
4139 let removal = remember_removal("/admin");
4140 assert_eq!(removal.name(), REMEMBER_COOKIE);
4141 assert_eq!(removal.path(), Some("/admin"));
4142 }
4143}