1mod audit;
18pub mod bootstrap;
19mod bulk;
20mod clientip;
21mod error;
22mod export;
23pub mod field;
24pub mod form;
25pub mod html;
26pub mod http_cache;
27mod icons;
28pub mod list;
29pub mod persist;
30pub mod picker;
31pub mod plugins;
32mod roles;
33mod routemap;
34pub mod routes;
35mod session;
36pub mod settings;
37mod sql;
38mod upload;
39mod users;
40
41pub use axum;
49
50pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
51pub use error::AdminError;
52pub use session::{FlashLevel, SessionHandle};
53pub use upload::VerifiedUpload;
54
55use std::collections::HashMap;
56use std::sync::{Arc, RwLock};
57
58use askama::Template;
59use axum::body::Body;
60use axum::extract::{Path, Query, Request, State};
61use axum::http::{header, HeaderMap};
62use axum::middleware::{self, Next};
63use axum::response::{Html, IntoResponse, Redirect, Response};
64use axum::routing::{any, get, post};
65use axum::{Extension, Form, Router};
66use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
67use chrono_tz::{Tz, TZ_VARIANTS};
68use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
69use laterite_core::{t, CatalogStore, Db, Text, Translator};
70use serde::{Deserialize, Serialize};
71
72pub trait AdminRegistry {
78 fn add_resource(&mut self, resource: Resource);
80 fn add_permission(&mut self, permission: Permission);
82 fn add_settings(&mut self, item: settings::SettingsItem);
84 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
86 fn add_persister(&mut self, persister: persist::PersisterReg);
88 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
90 fn add_screen(&mut self, screen: routes::ScreenReg);
93 fn add_public_route(&mut self, route: routes::PublicRouteReg);
96}
97
98impl AdminRegistry for laterite_core::Registry {
99 fn add_resource(&mut self, resource: Resource) {
100 self.add(resource);
101 }
102 fn add_permission(&mut self, permission: Permission) {
103 self.add(permission);
104 }
105 fn add_settings(&mut self, item: settings::SettingsItem) {
106 self.add(item);
107 }
108 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
109 self.add(source);
110 }
111 fn add_screen(&mut self, screen: routes::ScreenReg) {
112 self.add(screen);
113 }
114 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
115 self.add(route);
116 }
117 fn add_persister(&mut self, persister: persist::PersisterReg) {
118 self.add(persister);
119 }
120 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
121 self.add(field_type);
122 }
123}
124
125const SESSION_COOKIE: &str = "laterite_session";
126const REMEMBER_COOKIE: &str = "laterite_remember";
130
131#[derive(Clone)]
133pub(crate) struct AdminState {
134 auth: AuthService,
135 db: Db,
136 nav: Arc<Vec<NavLink>>,
137 settings: Arc<Vec<settings::SettingsItem>>,
138 permissions: Arc<Vec<Permission>>,
139 admin_path: Arc<str>,
143 secure_cookie: bool,
144 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
146 origin: Arc<str>,
148 timezone: Tz,
149 default_locale: Arc<str>,
153 catalogs: Arc<CatalogStore>,
156 app_name: String,
159 brand_cache: Arc<RwLock<Option<String>>>,
163 asset_urls: Arc<AssetUrls>,
165 field_types: Arc<field::FieldRegistry>,
168 column_types: Arc<list::ColumnRegistry>,
171 plugin_defined: Arc<laterite_core::Registry>,
174 assets: Arc<AssetRegistry>,
176 pickers: Arc<picker::PickerRegistry>,
179 overrides: Arc<dyn field::OverrideResolver>,
183}
184
185impl AdminState {
186 #[cfg(test)]
187 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
188 Self {
189 auth,
190 db,
191 nav: Arc::new(Vec::new()),
192 settings: Arc::new(Vec::new()),
193 permissions: Arc::new(builtin_permissions()),
194 trusted_proxies: Arc::new(Vec::new()),
195 admin_path: Arc::from("/admin"),
196 secure_cookie: false,
197 origin: Arc::from(""),
198 timezone: Tz::UTC,
199 default_locale: Arc::from("en"),
200 catalogs: Arc::new(CatalogStore::default()),
201 app_name: "Laterite".to_string(),
202 brand_cache: Arc::new(RwLock::new(None)),
203 field_types: Arc::new(field::builtin_registry()),
204 column_types: Arc::new(list::builtin_column_registry()),
205 plugin_defined: Arc::new(laterite_core::Registry::new()),
206 assets: Arc::new(builtin_assets()),
207 asset_urls: Arc::new(asset_urls(&builtin_assets())),
208 pickers: Arc::new(picker::PickerRegistry::new()),
209 overrides: Arc::new(field::NoOverrides),
210 }
211 }
212
213 async fn brand(&self) -> String {
217 {
218 let cached = self.brand_cache.read().unwrap().clone();
219 if let Some(name) = cached {
220 return name;
221 }
222 }
223 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
224 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
225 _ => self.app_name.clone(),
226 };
227 *self.brand_cache.write().unwrap() = Some(resolved.clone());
228 resolved
229 }
230
231 fn invalidate_brand(&self) {
233 *self.brand_cache.write().unwrap() = None;
234 }
235}
236
237#[derive(Clone)]
243#[non_exhaustive]
244pub struct AdminConfig {
245 pub secure_cookie: bool,
248 pub trusted_proxies: Vec<String>,
252 pub timezone: String,
256 pub locale: String,
260 pub app_name: String,
264 pub path: String,
268 pub origin: String,
273}
274
275impl Default for AdminConfig {
276 fn default() -> Self {
277 Self {
278 secure_cookie: false,
279 trusted_proxies: Vec::new(),
280 timezone: "UTC".to_string(),
281 locale: "en".to_string(),
282 app_name: "Laterite".to_string(),
283 path: "/admin".to_string(),
284 origin: String::new(),
285 }
286 }
287}
288
289#[derive(Clone)]
290struct NavLink {
291 label: Text,
292 path: String,
293 icon: Option<&'static str>,
296}
297
298#[derive(Clone)]
303pub(crate) struct Shell {
304 pub(crate) base: String,
308 brand: String,
312 nav: Vec<NavView>,
313 full_name: String,
314 initial: String,
315 tz: Tz,
319 i18n: Translator,
323 sidebar: Vec<settings::CategoryView>,
327 asset_urls: Arc<AssetUrls>,
331 pub(crate) csrf_token: String,
335 pub(crate) flash: Vec<FlashLine>,
339 pub(crate) assets: Vec<PageAsset>,
343}
344
345#[derive(Clone)]
347pub(crate) struct PageAsset {
348 pub(crate) url: String,
349 pub(crate) css: bool,
350}
351
352#[derive(Clone)]
355pub(crate) struct FlashLine {
356 pub(crate) level: session::FlashLevel,
357 pub(crate) text: String,
358}
359
360impl Shell {
361 #[allow(clippy::too_many_arguments)]
362 fn new(
363 base: &str,
364 brand: String,
365 nav: &[NavLink],
366 user: &AuthenticatedUser,
367 default_tz: Tz,
368 sidebar: Vec<settings::CategoryView>,
369 active_nav: Option<&str>,
370 csrf_token: String,
371 flash: Vec<session::Flash>,
372 i18n: Translator,
373 asset_urls: Arc<AssetUrls>,
374 ) -> Self {
375 let full_name = user.user.full_name();
376 let initial = full_name
377 .chars()
378 .next()
379 .map(|c| c.to_uppercase().to_string())
380 .unwrap_or_else(|| "?".to_string());
381 let flash = flash
384 .into_iter()
385 .map(|f| FlashLine {
386 level: f.level,
387 text: i18n.t(&f.text),
388 })
389 .collect();
390 let nav = nav
391 .iter()
392 .map(|n| NavView {
393 label: i18n.t(&n.label),
394 path: n.path.clone(),
395 active: active_nav == Some(n.path.as_str()),
396 icon: n.icon.map(|name| icons::svg(Some(name))).unwrap_or(""),
397 })
398 .collect();
399 Shell {
400 asset_urls,
401 base: base.to_string(),
402 brand,
403 nav,
404 full_name,
405 initial,
406 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
407 i18n,
408 sidebar,
409 csrf_token,
410 flash,
411 assets: Vec::new(),
412 }
413 }
414
415 pub(crate) fn asset(&self, key: &str) -> String {
420 match self.asset_urls.get(key) {
421 Some(path) => format!("{}/assets/{path}", self.base),
422 None => {
423 debug_assert!(false, "asset key `{key}` is not registered");
424 format!("{}/assets/{key}", self.base)
425 }
426 }
427 }
428
429 pub(crate) fn t(&self, source: &str) -> String {
432 self.i18n.t(&Text::dynamic(source))
433 }
434
435 pub(crate) fn tt(&self, text: &Text) -> String {
438 self.i18n.t(text)
439 }
440
441 pub(crate) fn i18n(&self) -> &Translator {
444 &self.i18n
445 }
446
447 pub(crate) fn locale(&self) -> &str {
449 self.i18n.locale()
450 }
451
452 pub(crate) fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
456 let mut text = Text::dynamic(source);
457 for (name, value) in args {
458 text = text.arg(*name, *value);
459 }
460 self.i18n.t(&text)
461 }
462
463 pub(crate) fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
466 let mut text = Text::dynamic(source);
467 for (name, value) in args {
468 text = text.arg(*name, *value);
469 }
470 self.i18n.t(&text)
471 }
472
473 #[cfg(test)]
474 pub(crate) fn test() -> Self {
475 Shell {
476 asset_urls: Arc::new(asset_urls(&builtin_assets())),
477 base: "/admin".to_string(),
478 brand: "Laterite".to_string(),
479 nav: Vec::new(),
480 full_name: "Test Operator".to_string(),
481 initial: "T".to_string(),
482 tz: Tz::UTC,
483 i18n: Translator::new("en"),
484 sidebar: Vec::new(),
485 csrf_token: "test-csrf-token".to_string(),
486 flash: Vec::new(),
487 assets: Vec::new(),
488 }
489 }
490}
491
492fn settings_context(
499 visible: &[settings::SettingsItem],
500 admin_path: &str,
501 path: &str,
502) -> (bool, Option<String>) {
503 if path == format!("{admin_path}/settings") {
504 return (true, None);
505 }
506 let own = visible
512 .iter()
513 .filter(|i| i.link.is_none())
514 .find(|i| path == format!("{admin_path}{}", i.route()))
515 .map(|i| i.code.clone());
516 if own.is_some() {
517 return (true, own);
518 }
519 let linked = visible
520 .iter()
521 .filter_map(|i| {
522 i.link
523 .as_deref()
524 .map(|link| (format!("{admin_path}{link}"), &i.code))
525 })
526 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
527 .max_by_key(|(link, _)| link.len())
528 .map(|(_, code)| code.clone());
529 (linked.is_some(), linked)
530}
531
532fn active_nav_path(
540 nav: &[NavLink],
541 admin_path: &str,
542 in_settings_context: bool,
543 path: &str,
544) -> Option<String> {
545 if in_settings_context {
546 return Some(format!("{admin_path}/settings"));
547 }
548 nav.iter()
549 .filter(|n| {
550 path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
551 })
552 .max_by_key(|n| n.path.len())
553 .map(|n| n.path.clone())
554}
555
556fn resolve_nav_context(
561 nav: &[NavLink],
562 items: &[settings::SettingsItem],
563 admin_path: &str,
564 perms: &PermissionSet,
565 path: &str,
566 tr: &Translator,
567) -> (Vec<settings::CategoryView>, Option<String>) {
568 let visible = visible_settings(items, perms);
569 let (in_context, active) = settings_context(&visible, admin_path, path);
570 let sidebar = if in_context {
571 settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr)
572 } else {
573 Vec::new()
574 };
575 let active_nav = active_nav_path(nav, admin_path, in_context, path);
576 (sidebar, active_nav)
577}
578
579fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
583 preference
584 .and_then(|name| name.parse::<Tz>().ok())
585 .unwrap_or(default_tz)
586}
587
588fn zone_offered(name: &str) -> bool {
590 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
591}
592
593fn base_lang(tag: &str) -> String {
596 tag.split(['-', '_'])
597 .next()
598 .unwrap_or(tag)
599 .to_ascii_lowercase()
600}
601
602fn is_serveable(base: &str, serveable: &[String]) -> bool {
608 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
609}
610
611fn default_locale(configured: &str, serveable: &[String]) -> String {
614 let base = base_lang(configured);
615 if is_serveable(&base, serveable) {
616 base
617 } else {
618 "en".to_string()
619 }
620}
621
622fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
626 let mut out = vec!["en".to_string()];
627 out.extend(catalogs.locales());
628 out
629}
630
631fn locale_name(code: &str) -> String {
636 if code == "en" {
637 "English".to_string()
638 } else {
639 code.to_string()
640 }
641}
642
643fn parse_accept_language(header: &str) -> Vec<String> {
646 let mut items: Vec<(f32, String)> = header
647 .split(',')
648 .filter_map(|part| {
649 let mut bits = part.split(';');
650 let tag = bits.next()?.trim();
651 if tag.is_empty() || tag == "*" {
652 return None;
653 }
654 let q = bits
655 .find_map(|p| p.trim().strip_prefix("q="))
656 .and_then(|q| q.parse::<f32>().ok())
657 .unwrap_or(1.0);
658 Some((q, tag.to_string()))
659 })
660 .collect();
661 items.sort_by(|a, b| b.0.total_cmp(&a.0));
663 items.into_iter().map(|(_, tag)| tag).collect()
664}
665
666fn resolve_locale_chain(
672 preference: Option<&str>,
673 accept_language: Option<&str>,
674 default: &str,
675 serveable: &[String],
676) -> Vec<String> {
677 let mut chain: Vec<String> = Vec::new();
678 let consider = |tag: &str, chain: &mut Vec<String>| {
679 let base = base_lang(tag);
680 if is_serveable(&base, serveable) && !chain.contains(&base) {
681 chain.push(base);
682 }
683 };
684 if let Some(pref) = preference {
685 consider(pref, &mut chain);
686 }
687 if let Some(header) = accept_language {
688 for tag in parse_accept_language(header) {
689 consider(&tag, &mut chain);
690 }
691 }
692 consider(default, &mut chain);
693 consider("en", &mut chain);
696 chain
697}
698
699fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
703 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
704 let serveable = state.catalogs.locales();
705 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
706 Translator::with_chain(chain, state.catalogs.clone())
707}
708
709#[derive(Serialize)]
717#[non_exhaustive]
718pub struct Resource {
719 pub base_path: String,
725 pub nav_label: Text,
727 pub list: list::ListConfig,
728 pub form: Option<form::FormConfig>,
729 pub permission: Option<String>,
736}
737
738#[derive(Clone, Serialize)]
743pub struct Permission {
744 pub code: String,
745 pub label: Text,
747 pub group: Text,
748}
749
750impl Resource {
751 pub fn new(
756 base_path: impl Into<String>,
757 nav_label: impl Into<Text>,
758 list: list::ListConfig,
759 ) -> Self {
760 Self {
761 base_path: base_path.into(),
762 nav_label: nav_label.into(),
763 list,
764 form: None,
765 permission: None,
766 }
767 }
768
769 pub fn form(mut self, form: form::FormConfig) -> Self {
772 self.form = Some(form);
773 self
774 }
775
776 pub fn permission(mut self, permission: impl Into<String>) -> Self {
778 self.permission = Some(permission.into());
779 self
780 }
781}
782
783fn builtin_permissions() -> Vec<Permission> {
786 vec![
787 Permission {
788 code: "backend.manage_users".to_string(),
789 label: "Manage backend users".into(),
790 group: "Backend".into(),
791 },
792 Permission {
793 code: "backend.manage_roles".to_string(),
794 label: "Manage roles".into(),
795 group: "Backend".into(),
796 },
797 Permission {
798 code: "backend.manage_branding".to_string(),
799 label: "Manage branding".into(),
800 group: "Backend".into(),
801 },
802 Permission {
803 code: plugins::MANAGE_PERMISSION.to_string(),
804 label: "Manage plugins".into(),
805 group: "Backend".into(),
806 },
807 Permission {
808 code: "backend.view_audit_log".to_string(),
809 label: "View the audit log".into(),
810 group: "Backend".into(),
811 },
812 ]
813}
814
815pub fn descriptor_sources() -> Vec<String> {
821 let mut out = Vec::new();
822 out.extend(laterite_core::collect_sources(&builtin_resources()));
823 out.extend(laterite_core::collect_sources(&builtin_settings()));
824 out.extend(laterite_core::collect_sources(&builtin_permissions()));
825 out.sort();
826 out.dedup();
827 out
828}
829
830pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
845 builtin_modules().iter().map(|m| m.migrations()).collect()
846}
847
848pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
852 vec![
853 Box::new(laterite_auth::AuthModule),
854 Box::new(settings::SettingsModule),
855 Box::new(plugins::PluginsModule),
856 ]
857}
858
859#[derive(Default)]
883pub struct Contributions {
884 pub resources: Vec<Resource>,
886 pub settings: Vec<settings::SettingsItem>,
888 pub permissions: Vec<Permission>,
890 pub picker_sources: Vec<picker::PickerSourceReg>,
892 pub persisters: Vec<persist::PersisterReg>,
894 pub listeners: Vec<laterite_core::ModelListenerReg>,
896 pub field_types: Vec<field::FieldTypeReg>,
900 pub column_types: Vec<list::ColumnTypeReg>,
901 pub screens: Vec<routes::ScreenReg>,
903 pub public_routes: Vec<routes::PublicRouteReg>,
905 pub plugin_defined: Arc<laterite_core::Registry>,
910}
911
912pub fn router(
913 auth: AuthService,
914 db: Db,
915 contributions: Contributions,
916 config: AdminConfig,
917 catalogs: Arc<CatalogStore>,
918) -> Router {
919 let Contributions {
920 resources: app_resources,
921 settings: app_settings,
922 permissions: app_permissions,
923 picker_sources: app_picker_sources,
924 persisters: app_persisters,
925 listeners: app_listeners,
926 field_types: app_field_types,
927 column_types: app_column_types,
928 screens: app_screens,
929 public_routes: app_public,
930 plugin_defined,
931 } = contributions;
932 let admin_path = normalize_path(&config.path);
933
934 let mut resources = builtin_resources();
935 let mut app_resources = app_resources;
936 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
940 prefix_resource(&admin_path, resource);
941 }
942
943 let mut settings = builtin_settings();
944 settings.extend(app_settings);
945 let mut permissions = builtin_permissions();
946 permissions.extend(app_permissions);
947
948 let mut nav = vec![NavLink {
952 label: "Dashboard".into(),
953 path: admin_path.clone(),
954 icon: Some("layout-dashboard"),
955 }];
956 for resource in &app_resources {
957 nav.push(NavLink {
958 label: resource.nav_label.clone(),
959 path: resource.base_path.clone(),
960 icon: None,
961 });
962 }
963 for reg in &app_screens {
966 if let Some(label) = ®.nav_label {
967 nav.push(NavLink {
968 label: label.clone(),
969 path: format!("{admin_path}{}", reg.base_path),
970 icon: None,
971 });
972 }
973 }
974 nav.push(NavLink {
975 label: "Settings".into(),
976 path: format!("{admin_path}/settings"),
977 icon: Some("settings"),
978 });
979 resources.extend(app_resources);
980
981 let app_name = if config.app_name.trim().is_empty() {
982 "Laterite".to_string()
983 } else {
984 config.app_name.clone()
985 };
986
987 let mut pickers = picker::PickerRegistry::new();
990 for reg in app_picker_sources {
991 let name = reg.name.clone();
992 if !field::is_name(&name, true) {
993 panic!("invalid picker source name `{name}`");
994 }
995 if pickers.insert(name.clone(), reg).is_some() {
996 panic!("duplicate picker source `{name}`");
997 }
998 }
999
1000 let pickers = Arc::new(pickers);
1004 let mut field_types = field::builtin_registry();
1005 field_types.insert(
1006 "reference".to_string(),
1007 Arc::new(field::RefPickerField::new(pickers.clone())),
1008 );
1009
1010 let mut persisters = persist::PersisterRegistry::new();
1013 for reg in app_persisters {
1014 let name = reg.name.clone();
1015 if !field::is_name(&name, true) {
1016 panic!("invalid persister name `{name}`");
1017 }
1018 if persisters.insert(name.clone(), reg.persister).is_some() {
1019 panic!("duplicate persister `{name}`");
1020 }
1021 }
1022
1023 for reg in app_field_types {
1029 let name = reg.name().to_string();
1030 if !field::is_name(&name, true) {
1031 panic!("invalid field type name `{name}`");
1032 }
1033 if field_types.insert(name.clone(), reg.field_type).is_some() {
1034 panic!("duplicate field type `{name}`");
1035 }
1036 }
1037
1038 let mut column_types = list::builtin_column_registry();
1042 for reg in app_column_types {
1043 let name = reg.name().to_string();
1044 if !field::is_name(&name, true) {
1045 panic!("invalid column type name `{name}`");
1046 }
1047 if column_types.insert(name.clone(), reg.column_type).is_some() {
1048 panic!("duplicate column type `{name}`");
1049 }
1050 }
1051
1052 let settings = Arc::new(settings);
1053 let state = AdminState {
1054 auth,
1055 db,
1056 nav: Arc::new(nav),
1057 settings: settings.clone(),
1058 permissions: Arc::new(permissions),
1059 admin_path: Arc::from(admin_path.as_str()),
1060 secure_cookie: config.secure_cookie,
1061 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1062 origin: Arc::from(config.origin.trim_end_matches('/')),
1063 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1064 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1065 catalogs,
1066 app_name,
1067 brand_cache: Arc::new(RwLock::new(None)),
1068 field_types: Arc::new(field_types),
1069 column_types: Arc::new(column_types),
1070 plugin_defined,
1071 assets: Arc::new(builtin_assets()),
1072 asset_urls: Arc::new(asset_urls(&builtin_assets())),
1073 pickers,
1074 overrides: Arc::new(field::NoOverrides),
1075 };
1076
1077 let mut protected = Router::new().route(&admin_path, get(dashboard));
1078 for resource in &resources {
1079 protected = protected.merge(mount_resource(
1080 resource,
1081 &state.field_types,
1082 &persisters,
1083 &app_listeners,
1084 ));
1085 }
1086 for reg in &app_screens {
1090 let base = format!("{admin_path}{}", reg.base_path);
1091 let ctx = routes::RouteCtx::new(
1092 state.db.clone(),
1093 &base,
1094 &admin_path,
1095 &state.origin,
1096 state.plugin_defined.clone(),
1097 );
1098 protected = protected.merge(guard_with_permission(
1099 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1100 ®.permission,
1101 ));
1102 }
1103
1104 protected = protected.merge(guard_with_permission(
1107 Router::new()
1108 .route(
1109 &format!("{admin_path}/roles/new"),
1110 get(roles::new_form).post(roles::create),
1111 )
1112 .route(
1113 &format!("{admin_path}/roles/{{id}}/edit"),
1114 get(roles::edit_form).post(roles::update),
1115 ),
1116 "backend.manage_roles",
1117 ));
1118 protected = protected.merge(guard_with_permission(
1121 Router::new().route(
1122 &format!("{admin_path}/users/{{id}}/edit"),
1123 get(users::edit_form).post(users::update),
1124 ),
1125 "backend.manage_users",
1126 ));
1127 protected = protected.merge(guard_with_permission(
1130 Router::new()
1131 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1132 .route(
1133 &format!("{admin_path}/plugins/toggle"),
1134 post(plugins::toggle),
1135 ),
1136 plugins::MANAGE_PERMISSION,
1137 ));
1138 protected = protected
1139 .route(&format!("{admin_path}/settings"), get(settings_index))
1140 .route(
1141 &format!("{admin_path}/preferences"),
1142 get(preferences_form).post(preferences_update),
1143 )
1144 .route(
1145 &format!("{admin_path}/preferences/sessions/revoke"),
1146 post(session_revoke),
1147 )
1148 .route(
1149 &format!("{admin_path}/preferences/sessions/revoke-others"),
1150 post(session_revoke_others),
1151 )
1152 .route(
1155 &format!("{admin_path}/pickers/{{source}}/search"),
1156 any(picker::search),
1157 )
1158 .route(
1159 &format!("{admin_path}/pickers/{{source}}/resolve"),
1160 any(picker::resolve),
1161 )
1162 .route(&format!("{admin_path}/logout"), post(logout));
1163
1164 for item in settings.iter().filter(|i| i.link.is_none()) {
1168 let path = format!("{admin_path}{}", item.route());
1169 let code = item.code.clone();
1170 let edit_code = code.clone();
1171 protected = protected.route(
1172 &path,
1173 get(
1174 move |State(state): State<AdminState>,
1175 Extension(shell): Extension<Shell>,
1176 Extension(user): Extension<AuthenticatedUser>| {
1177 let code = edit_code.clone();
1178 async move { settings_edit(state, shell, user, code).await }
1179 },
1180 )
1181 .post(
1182 move |State(state): State<AdminState>,
1183 Extension(shell): Extension<Shell>,
1184 Extension(user): Extension<AuthenticatedUser>,
1185 Extension(session): Extension<session::SessionHandle>,
1186 Form(data): Form<HashMap<String, String>>| {
1187 let code = code.clone();
1188 async move { settings_update(state, shell, user, session, code, data).await }
1189 },
1190 ),
1191 );
1192 }
1193
1194 protected
1195 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1196 .route(
1200 &format!("{admin_path}/login"),
1201 get(login_form).post(login_submit),
1202 )
1203 .route(
1204 &format!("{admin_path}/setup"),
1205 get(setup_form).post(setup_submit),
1206 )
1207 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1208 .merge(mount_public(
1212 &app_public,
1213 &state.db,
1214 &admin_path,
1215 &state.origin,
1216 &state.plugin_defined,
1217 ))
1218 .fallback(not_found_fallback)
1220 .layer(middleware::from_fn_with_state(
1223 state.clone(),
1224 enforce_origin,
1225 ))
1226 .layer(middleware::from_fn_with_state(
1229 state.clone(),
1230 capture_client,
1231 ))
1232 .layer(
1237 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1238 header::CACHE_CONTROL,
1239 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1240 ),
1241 )
1242 .layer(
1246 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1247 header::VARY,
1248 axum::http::HeaderValue::from_static("HX-Request"),
1249 ),
1250 )
1251 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1252 handle_panic,
1253 ))
1254 .with_state(state)
1255}
1256
1257async fn not_found_fallback() -> AdminError {
1259 AdminError::NotFound
1260}
1261
1262fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1264 let cause = err
1265 .downcast_ref::<&str>()
1266 .map(|s| s.to_string())
1267 .or_else(|| err.downcast_ref::<String>().cloned())
1268 .unwrap_or_else(|| "unknown panic".to_string());
1269 tracing::error!(panic = cause, "admin handler panicked");
1270 error::masked_500()
1271}
1272
1273pub fn normalize_path(path: &str) -> String {
1278 let trimmed = path.trim().trim_matches('/');
1279 if trimmed.is_empty() {
1280 "/admin".to_string()
1281 } else {
1282 format!("/{trimmed}")
1283 }
1284}
1285
1286fn mount_public(
1290 regs: &[routes::PublicRouteReg],
1291 db: &Db,
1292 admin_path: &str,
1293 origin: &str,
1294 plugin_defined: &Arc<laterite_core::Registry>,
1295) -> Router<AdminState> {
1296 let mut router = Router::new();
1297 for reg in regs {
1298 let ctx = routes::RouteCtx::new(
1299 db.clone(),
1300 ®.path,
1301 admin_path,
1302 origin,
1303 plugin_defined.clone(),
1304 );
1305 router = router.nest_service(
1310 ®.path,
1311 reg.route.mount(&ctx).fallback(not_found_fallback),
1312 );
1313 }
1314 router
1315}
1316
1317pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1320 let old = resource.base_path.clone();
1321 resource.base_path = base.to_string();
1322 if let Some(edit_base) = &mut resource.list.edit_base {
1323 if edit_base == &old {
1324 *edit_base = base.to_string();
1325 }
1326 }
1327 if let Some(form) = &mut resource.form {
1328 if form.base_path == old {
1329 form.base_path = base.to_string();
1330 }
1331 }
1332}
1333
1334fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1335 resource.base_path = format!("{admin_path}{}", resource.base_path);
1336 if let Some(edit_base) = &mut resource.list.edit_base {
1337 *edit_base = format!("{admin_path}{edit_base}");
1338 }
1339 if let Some(form) = &mut resource.form {
1340 form.base_path = format!("{admin_path}{}", form.base_path);
1341 }
1342}
1343
1344pub(crate) struct AdminAsset {
1346 pub mime: &'static str,
1347 pub bytes: &'static [u8],
1348}
1349
1350pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1353
1354pub(crate) type AssetUrls = HashMap<&'static str, String>;
1358
1359pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
1361 registry
1362 .iter()
1363 .map(|(&key, asset)| {
1364 (
1365 key,
1366 http_cache::fingerprint(key, &http_cache::digest(asset.bytes)),
1367 )
1368 })
1369 .collect()
1370}
1371
1372pub(crate) fn page_assets(
1379 keys: &[&str],
1380 base: &str,
1381 registry: &AssetRegistry,
1382 urls: &AssetUrls,
1383) -> Vec<PageAsset> {
1384 let mut seen = std::collections::HashSet::new();
1385 let mut out = Vec::new();
1386 for &key in keys {
1387 if !seen.insert(key) {
1388 continue;
1389 }
1390 match (registry.get(key), urls.get(key)) {
1391 (Some(asset), Some(path)) => out.push(PageAsset {
1392 url: format!("{base}/assets/{path}"),
1393 css: asset.mime.contains("css"),
1394 }),
1395 _ => debug_assert!(false, "asset key `{key}` is not registered"),
1396 }
1397 }
1398 out
1399}
1400
1401pub(crate) fn builtin_assets() -> AssetRegistry {
1410 let font = |bytes: &'static [u8]| AdminAsset {
1411 mime: "font/woff2",
1412 bytes,
1413 };
1414 HashMap::from([
1415 (
1416 "laterite.css",
1417 AdminAsset {
1418 mime: "text/css; charset=utf-8",
1419 bytes: include_bytes!("../assets/laterite.css"),
1420 },
1421 ),
1422 (
1423 "laterite.js",
1424 AdminAsset {
1425 mime: "text/javascript; charset=utf-8",
1426 bytes: include_bytes!("../assets/laterite.js"),
1427 },
1428 ),
1429 (
1430 "vendor/htmx.min.js",
1431 AdminAsset {
1432 mime: "text/javascript; charset=utf-8",
1433 bytes: include_bytes!("../assets/vendor/htmx.min.js"),
1434 },
1435 ),
1436 (
1437 "fields/ref-picker.js",
1438 AdminAsset {
1439 mime: "text/javascript; charset=utf-8",
1440 bytes: include_bytes!("../assets/fields/ref-picker.js"),
1441 },
1442 ),
1443 (
1444 "fields/ref-picker.css",
1445 AdminAsset {
1446 mime: "text/css; charset=utf-8",
1447 bytes: include_bytes!("../assets/fields/ref-picker.css"),
1448 },
1449 ),
1450 (
1451 "mark.svg",
1452 AdminAsset {
1453 mime: "image/svg+xml",
1454 bytes: include_bytes!("../assets/mark.svg"),
1455 },
1456 ),
1457 (
1458 "mark.png",
1459 AdminAsset {
1460 mime: "image/png",
1461 bytes: include_bytes!("../assets/mark.png"),
1462 },
1463 ),
1464 (
1465 "fonts/space-grotesk-500.woff2",
1466 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1467 ),
1468 (
1469 "fonts/space-grotesk-600.woff2",
1470 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1471 ),
1472 (
1473 "fonts/space-grotesk-700.woff2",
1474 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1475 ),
1476 (
1477 "fonts/ibm-plex-sans-400.woff2",
1478 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1479 ),
1480 (
1481 "fonts/ibm-plex-sans-600.woff2",
1482 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1483 ),
1484 (
1485 "fonts/ibm-plex-mono-400.woff2",
1486 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1487 ),
1488 (
1489 "fonts/ibm-plex-mono-600.woff2",
1490 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1491 ),
1492 ])
1493}
1494
1495async fn serve_asset(
1497 State(state): State<AdminState>,
1498 headers: HeaderMap,
1499 Path(path): Path<String>,
1500) -> Response {
1501 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
1505 let Some(asset) = state.assets.get(key.as_str()) else {
1506 return not_found();
1507 };
1508 let policy = if fingerprinted {
1509 http_cache::IMMUTABLE
1510 } else {
1511 http_cache::REVALIDATE
1512 };
1513 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
1514}
1515
1516fn mount_resource(
1521 resource: &Resource,
1522 field_types: &field::FieldRegistry,
1523 persisters: &persist::PersisterRegistry,
1524 listeners: &[laterite_core::ModelListenerReg],
1525) -> Router<AdminState> {
1526 if resource.form.is_some() && resource.permission.is_none() {
1531 panic!(
1532 "admin resource `{}` has a create/edit form but no permission; a write \
1533 resource must declare a permission",
1534 resource.base_path
1535 );
1536 }
1537 let base = resource.base_path.clone();
1538 let list_path = base.clone();
1539 let list_cfg = resource.list.clone();
1540 let mut router = Router::new().route(
1541 &base,
1542 get(
1543 move |State(state): State<AdminState>,
1544 Extension(shell): Extension<Shell>,
1545 Query(params): Query<list::ListParams>,
1546 Query(raw): Query<std::collections::HashMap<String, String>>,
1547 Extension(user): Extension<AuthenticatedUser>,
1548 headers: axum::http::HeaderMap| {
1549 let cfg = list_cfg.clone();
1550 let path = list_path.clone();
1551 async move {
1552 list::handle(&state, &cfg, &path, params, &raw, &user, shell, &headers).await
1553 }
1554 },
1555 ),
1556 );
1557
1558 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
1559 router =
1560 router.route(
1561 &format!("{base}/export"),
1562 get(
1563 move |state: State<AdminState>,
1564 Query(raw): Query<std::collections::HashMap<String, String>>,
1565 Extension(user): Extension<AuthenticatedUser>,
1566 Extension(shell): Extension<Shell>,
1567 Extension(session): Extension<session::SessionHandle>| {
1568 let cfg = export_cfg.clone();
1569 let path = export_path.clone();
1570 async move {
1571 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
1572 }
1573 },
1574 ),
1575 );
1576
1577 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
1578 router = router.route(
1579 &format!("{base}/columns"),
1580 post(
1581 move |State(state): State<AdminState>,
1582 Extension(user): Extension<AuthenticatedUser>,
1583 Extension(session): Extension<session::SessionHandle>,
1584 headers: axum::http::HeaderMap,
1585 Form(pairs): Form<Vec<(String, String)>>| {
1586 let cfg = columns_cfg.clone();
1587 let path = columns_path.clone();
1588 async move {
1589 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
1590 }
1591 },
1592 ),
1593 );
1594
1595 if resource.list.deletable {
1596 let persister: Arc<dyn persist::Persister> =
1600 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
1601 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
1602 panic!(
1603 "admin resource `{}`: unregistered persister `{key}`",
1604 resource.base_path
1605 )
1606 }),
1607 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
1608 };
1609 let ctx = Arc::new(bulk::BulkContext {
1610 base_path: resource.base_path.clone(),
1611 entity: resource.list.entity.clone(),
1612 persister,
1613 listeners: listeners
1614 .iter()
1615 .filter(|reg| reg.matches(&resource.list.entity))
1616 .map(|reg| reg.listener.clone())
1617 .collect(),
1618 });
1619 router =
1620 router.route(
1621 &format!("{base}/delete"),
1622 post(
1623 move |state: State<AdminState>,
1624 user: Extension<AuthenticatedUser>,
1625 session: Extension<session::SessionHandle>,
1626 headers: axum::http::HeaderMap,
1627 form: Form<Vec<(String, String)>>| {
1628 let ctx = ctx.clone();
1629 async move {
1630 bulk::delete_handler(state, user, session, ctx, headers, form).await
1631 }
1632 },
1633 ),
1634 );
1635 }
1636
1637 if let Some(form_cfg) = resource.form.clone() {
1638 let prepared = Arc::new(
1641 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
1642 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1643 );
1644 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1645 router = router.route(
1646 &format!("{base}/new"),
1647 get(
1648 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1649 let pf = new_pf.clone();
1650 async move { form::new_form(&state, &pf, shell) }
1651 },
1652 )
1653 .post(
1654 move |State(state): State<AdminState>,
1655 Extension(shell): Extension<Shell>,
1656 Extension(user): Extension<AuthenticatedUser>,
1657 Extension(session): Extension<session::SessionHandle>,
1658 headers: axum::http::HeaderMap,
1659 Form(data): Form<HashMap<String, String>>| {
1660 let pf = create_pf.clone();
1661 async move {
1662 form::create(&state, &pf, data, shell, &user, &session, &headers).await
1663 }
1664 },
1665 ),
1666 );
1667
1668 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1669 router = router.route(
1670 &format!("{base}/{{id}}/edit"),
1671 get(
1672 move |State(state): State<AdminState>,
1673 Extension(shell): Extension<Shell>,
1674 Path(id): Path<String>| {
1675 let pf = edit_pf.clone();
1676 async move { form::edit_form(&state, &pf, id, shell).await }
1677 },
1678 )
1679 .post(
1680 move |State(state): State<AdminState>,
1681 Extension(shell): Extension<Shell>,
1682 Extension(user): Extension<AuthenticatedUser>,
1683 Extension(session): Extension<session::SessionHandle>,
1684 Path(id): Path<String>,
1685 headers: axum::http::HeaderMap,
1686 Form(data): Form<HashMap<String, String>>| {
1687 let pf = update_pf.clone();
1688 async move {
1689 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
1690 }
1691 },
1692 ),
1693 );
1694 }
1695
1696 if let Some(permission) = &resource.permission {
1698 router = guard_with_permission(router, permission);
1699 }
1700 router
1701}
1702
1703fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
1708 let needed: Arc<str> = Arc::from(permission);
1709 router.route_layer(middleware::from_fn(
1710 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
1711 let needed = needed.clone();
1712 async move {
1713 if user.allows(&needed) {
1714 next.run(req).await
1715 } else {
1716 forbidden()
1717 }
1718 }
1719 },
1720 ))
1721}
1722
1723async fn require_auth(
1729 State(state): State<AdminState>,
1730 jar: CookieJar,
1731 request: Request,
1732 next: Next,
1733) -> Response {
1734 let login = format!("{}/login", state.admin_path);
1735 let client_ctx = request
1736 .extensions()
1737 .get::<RequestContext>()
1738 .cloned()
1739 .unwrap_or_default();
1740 let live = match jar.get(SESSION_COOKIE) {
1744 Some(cookie) => {
1745 let token = cookie.value().to_string();
1746 state
1747 .auth
1748 .resolve_session(&token)
1749 .await
1750 .ok()
1751 .map(|resolved| (token, resolved))
1752 }
1753 None => None,
1754 };
1755 let (token, resolved, recalled) = match live {
1756 Some((token, resolved)) => (token, resolved, None),
1757 None => match recall(&state, &jar, &client_ctx).await {
1758 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
1759 None => {
1762 return (
1763 jar.remove(remember_removal(&state.admin_path)),
1764 Redirect::to(&login),
1765 )
1766 .into_response()
1767 }
1768 },
1769 };
1770 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
1771
1772 let mut request = request;
1777 let safe = session::is_safe_method(request.method());
1778 let mut deferred: Option<session::CsrfPending> = None;
1780 if !safe {
1781 let (parts, body) = request.into_parts();
1782 let (body, submitted) = if session::is_multipart(&parts.headers) {
1789 let token = session::header_token(&parts.headers)
1790 .or_else(|| session::query_token(parts.uri.query()));
1791 if token.is_none() {
1796 deferred = Some(session::CsrfPending::default());
1797 }
1798 (body, token)
1799 } else {
1800 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
1801 .await
1802 .unwrap_or_default();
1803 let token = session::submitted_token(&parts.headers, &bytes);
1804 (Body::from(bytes), token)
1805 };
1806 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
1807 {
1808 tracing::warn!(
1809 user_id = resolved.identity.user.id,
1810 "admin CSRF check failed"
1811 );
1812 return error::csrf_rejected();
1813 }
1814 request = Request::from_parts(parts, body);
1815 }
1816
1817 let flash = if safe {
1820 handle.take_flash()
1821 } else {
1822 Vec::new()
1823 };
1824 let user = resolved.identity;
1825 let acting_user_id = user.user.id;
1827 let path = request.uri().path().to_string();
1828 let accept_language = request
1832 .headers()
1833 .get("accept-language")
1834 .and_then(|v| v.to_str().ok());
1835 let serveable = state.catalogs.locales();
1836 let chain = resolve_locale_chain(
1837 user.user.locale.as_deref(),
1838 accept_language,
1839 &state.default_locale,
1840 &serveable,
1841 );
1842 let i18n = Translator::with_chain(chain, state.catalogs.clone());
1843 let translator = i18n.clone();
1846 let (sidebar, active_nav) = resolve_nav_context(
1847 &state.nav,
1848 &state.settings,
1849 &state.admin_path,
1850 &user.permissions,
1851 &path,
1852 &i18n,
1853 );
1854 let brand = state.brand().await;
1855 let shell = Shell::new(
1856 &state.admin_path,
1857 brand,
1858 &state.nav,
1859 &user,
1860 state.timezone,
1861 sidebar,
1862 active_nav.as_deref(),
1863 handle.csrf_token(),
1864 flash,
1865 i18n,
1866 state.asset_urls.clone(),
1867 );
1868 request.extensions_mut().insert(user);
1869 request.extensions_mut().insert(shell);
1870 request.extensions_mut().insert(handle.clone());
1871 if let Some(pending) = &deferred {
1873 request.extensions_mut().insert(pending.clone());
1874 }
1875 let mut response = next.run(request).await;
1876
1877 if let Some(pending) = &deferred {
1882 if !pending.was_satisfied() {
1883 tracing::error!(
1884 user_id = acting_user_id,
1885 "a multipart route returned without verifying its request token"
1886 );
1887 return error::csrf_rejected();
1888 }
1889 }
1890
1891 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
1895 response = error::localized_error(meta.kind, &translator);
1896 }
1897
1898 if let Some(blob) = handle.dirty_blob() {
1901 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
1902 tracing::error!(error = %e, "persisting admin session failed");
1903 }
1904 }
1905 if let Some(credential) = recalled {
1908 let jar = jar
1909 .add(session_cookie(
1910 token,
1911 &state.admin_path,
1912 state.secure_cookie,
1913 ))
1914 .add(remember_cookie(
1915 credential,
1916 &state.admin_path,
1917 state.secure_cookie,
1918 ));
1919 return (jar, response).into_response();
1920 }
1921 response
1922}
1923
1924async fn recall(
1928 state: &AdminState,
1929 jar: &CookieJar,
1930 ctx: &RequestContext,
1931) -> Option<(
1932 String,
1933 laterite_auth::ResolvedSession,
1934 laterite_auth::RememberCredential,
1935)> {
1936 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
1937 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
1938 let token = recalled.session.token.clone();
1939 let resolved = state.auth.resolve_session(&token).await.ok()?;
1940 Some((token, resolved, recalled.remember))
1941}
1942
1943const MAX_FORM_BYTES: usize = 1024 * 1024;
1946
1947async fn capture_client(
1955 State(state): State<AdminState>,
1956 mut request: Request,
1957 next: Next,
1958) -> Response {
1959 let peer = request
1960 .extensions()
1961 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
1962 .map(|info| info.0);
1963 let headers = request.headers();
1964 let ctx = RequestContext {
1965 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
1966 user_agent: clientip::user_agent(headers),
1967 };
1968 request.extensions_mut().insert(ctx);
1969 next.run(request).await
1970}
1971
1972async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
1973 if !session::is_safe_method(request.method())
1974 && !session::origin_ok(request.headers(), &state.origin)
1975 {
1976 tracing::warn!(
1977 path = request.uri().path(),
1978 "admin CSRF origin check failed"
1979 );
1980 return error::csrf_rejected();
1981 }
1982 next.run(request).await
1983}
1984
1985async fn login_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
1986 match state.auth.has_any_operator().await {
1988 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
1989 Ok(true) => render(LoginTemplate {
1990 base: state.admin_path.to_string(),
1991 asset_urls: state.asset_urls.clone(),
1992 brand: state.brand().await,
1993 error: None,
1994 i18n: pre_auth_translator(&state, &headers),
1995 }),
1996 Err(_) => render_error(),
1997 }
1998}
1999
2000#[derive(Deserialize)]
2001struct LoginForm {
2002 username: String,
2003 password: String,
2004 #[serde(default)]
2006 remember: Option<String>,
2007}
2008
2009async fn login_submit(
2010 State(state): State<AdminState>,
2011 jar: CookieJar,
2012 headers: HeaderMap,
2013 Extension(ctx): Extension<RequestContext>,
2014 Form(form): Form<LoginForm>,
2015) -> Response {
2016 match state
2017 .auth
2018 .authenticate(&form.username, &form.password, &ctx)
2019 .await
2020 {
2021 Ok(session) => {
2022 let wants_remember = form.remember.is_some();
2023 let user_id = session.user_id;
2024 let mut jar = jar.add(session_cookie(
2025 session.token,
2026 &state.admin_path,
2027 state.secure_cookie,
2028 ));
2029 if wants_remember {
2030 match state.auth.issue_remember(user_id).await {
2031 Ok(credential) => {
2032 jar = jar.add(remember_cookie(
2033 credential,
2034 &state.admin_path,
2035 state.secure_cookie,
2036 ));
2037 }
2038 Err(e) => {
2040 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
2041 }
2042 }
2043 }
2044 (jar, Redirect::to(&state.admin_path)).into_response()
2045 }
2046 Err(_) => {
2047 let i18n = pre_auth_translator(&state, &headers);
2048 render(LoginTemplate {
2049 base: state.admin_path.to_string(),
2050 asset_urls: state.asset_urls.clone(),
2051 brand: state.brand().await,
2052 error: Some(i18n.t(&t!("Invalid username or password."))),
2053 i18n,
2054 })
2055 }
2056 }
2057}
2058
2059fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
2068 Cookie::build((SESSION_COOKIE, token))
2069 .path(admin_path.to_string())
2070 .http_only(true)
2071 .secure(secure)
2072 .same_site(SameSite::Lax)
2073 .build()
2074}
2075
2076fn remember_cookie(
2079 credential: laterite_auth::RememberCredential,
2080 admin_path: &str,
2081 secure: bool,
2082) -> Cookie<'static> {
2083 let seconds = (credential.expires_at - chrono::Utc::now())
2084 .num_seconds()
2085 .max(0);
2086 Cookie::build((REMEMBER_COOKIE, credential.cookie))
2087 .path(admin_path.to_string())
2088 .http_only(true)
2089 .secure(secure)
2090 .same_site(SameSite::Lax)
2091 .max_age(cookie::time::Duration::seconds(seconds))
2092 .build()
2093}
2094
2095fn remember_removal(admin_path: &str) -> Cookie<'static> {
2097 Cookie::build((REMEMBER_COOKIE, ""))
2098 .path(admin_path.to_string())
2099 .build()
2100}
2101
2102#[derive(Deserialize)]
2103struct SetupForm {
2104 username: String,
2105 first_name: String,
2106 last_name: String,
2107 email: String,
2108 password: String,
2109 timezone: String,
2110}
2111
2112async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2115 match state.auth.has_any_operator().await {
2116 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2117 Ok(false) => render(setup_view(
2118 &state.admin_path,
2119 state.brand().await,
2120 state.timezone,
2121 None,
2122 pre_auth_translator(&state, &headers),
2123 state.asset_urls.clone(),
2124 )),
2125 Err(_) => render_error(),
2126 }
2127}
2128
2129async fn setup_submit(
2130 State(state): State<AdminState>,
2131 jar: CookieJar,
2132 headers: HeaderMap,
2133 Form(form): Form<SetupForm>,
2134) -> Response {
2135 match state.auth.has_any_operator().await {
2137 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2138 Ok(false) => {}
2139 Err(_) => return render_error(),
2140 }
2141
2142 let username = form.username.trim();
2143 let email = form.email.trim();
2144 let first_name = form.first_name.trim();
2145 let last_name = form.last_name.trim();
2146 let tz = form.timezone.trim();
2147 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
2148 {
2149 return render(setup_view(
2150 &state.admin_path,
2151 state.brand().await,
2152 state.timezone,
2153 Some(t!(
2154 "Username, first name, email, and password are all required."
2155 )),
2156 pre_auth_translator(&state, &headers),
2157 state.asset_urls.clone(),
2158 ));
2159 }
2160 if tz.parse::<Tz>().is_err() {
2162 return render(setup_view(
2163 &state.admin_path,
2164 state.brand().await,
2165 state.timezone,
2166 Some(t!("That is not a recognised timezone.")),
2167 pre_auth_translator(&state, &headers),
2168 state.asset_urls.clone(),
2169 ));
2170 }
2171
2172 let new = NewOperator {
2173 username,
2174 email,
2175 first_name,
2176 last_name: (!last_name.is_empty()).then_some(last_name),
2177 password: &form.password,
2178 timezone: Some(tz),
2179 };
2180 if state.auth.create_superuser(new).await.is_err() {
2181 return render(setup_view(
2182 &state.admin_path,
2183 state.brand().await,
2184 state.timezone,
2185 Some(t!(
2186 "Could not create the account. The username or email may already be taken."
2187 )),
2188 pre_auth_translator(&state, &headers),
2189 state.asset_urls.clone(),
2190 ));
2191 }
2192
2193 match state
2195 .auth
2196 .authenticate(username, &form.password, &RequestContext::default())
2197 .await
2198 {
2199 Ok(session) => {
2200 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
2201 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
2202 }
2203 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2204 }
2205}
2206
2207fn setup_view(
2210 admin_path: &str,
2211 brand: String,
2212 default_tz: Tz,
2213 error: Option<Text>,
2214 i18n: Translator,
2215 asset_urls: Arc<AssetUrls>,
2216) -> SetupTemplate {
2217 let default_name = default_tz.name();
2218 let zones = TZ_VARIANTS
2219 .iter()
2220 .filter(|tz| zone_offered(tz.name()))
2221 .map(|tz| TzOption {
2222 name: tz.name().to_string(),
2223 selected: tz.name() == default_name,
2224 })
2225 .collect();
2226 SetupTemplate {
2227 base: admin_path.to_string(),
2228 asset_urls,
2229 brand,
2230 zones,
2231 error: error.map(|e| i18n.t(&e)),
2232 i18n,
2233 }
2234}
2235
2236async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
2237 if let Some(cookie) = jar.get(SESSION_COOKIE) {
2238 let _ = state.auth.logout(cookie.value()).await;
2239 }
2240 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2243 let _ = state.auth.revoke_remember(cookie.value()).await;
2244 }
2245 let removal = Cookie::build((SESSION_COOKIE, ""))
2246 .path(state.admin_path.to_string())
2247 .build();
2248 (
2249 jar.remove(removal)
2250 .remove(remember_removal(&state.admin_path)),
2251 Redirect::to(&format!("{}/login", state.admin_path)),
2252 )
2253 .into_response()
2254}
2255
2256async fn dashboard(
2257 Extension(shell): Extension<Shell>,
2258 Extension(user): Extension<AuthenticatedUser>,
2259) -> Response {
2260 render(DashboardTemplate {
2261 username: user.user.username,
2262 shell,
2263 })
2264}
2265
2266fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
2269 match &item.permission {
2270 None => true,
2271 Some(p) => perms.allows(p),
2272 }
2273}
2274
2275fn visible_settings(
2279 items: &[settings::SettingsItem],
2280 perms: &PermissionSet,
2281) -> Vec<settings::SettingsItem> {
2282 items
2283 .iter()
2284 .filter(|item| operator_can_see(item, perms))
2285 .cloned()
2286 .collect()
2287}
2288
2289async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
2290 settings::index(shell)
2291}
2292
2293async fn settings_edit(
2294 state: AdminState,
2295 shell: Shell,
2296 user: AuthenticatedUser,
2297 code: String,
2298) -> Response {
2299 let items = visible_settings(&state.settings, &user.permissions);
2302 match items
2303 .iter()
2304 .find(|item| item.code == code && item.link.is_none())
2305 {
2306 Some(item) => settings::edit_form(&state, item, shell).await,
2307 None => not_found(),
2308 }
2309}
2310
2311async fn settings_update(
2312 state: AdminState,
2313 shell: Shell,
2314 user: AuthenticatedUser,
2315 session: session::SessionHandle,
2316 code: String,
2317 data: HashMap<String, String>,
2318) -> Response {
2319 let items = visible_settings(&state.settings, &user.permissions);
2320 match items
2321 .iter()
2322 .find(|item| item.code == code && item.link.is_none())
2323 {
2324 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
2325 None => not_found(),
2326 }
2327}
2328
2329async fn preferences_form(
2331 State(state): State<AdminState>,
2332 Extension(shell): Extension<Shell>,
2333 Extension(user): Extension<AuthenticatedUser>,
2334 jar: CookieJar,
2335) -> Response {
2336 let sessions = session_rows(&state, &user, &shell, &jar).await;
2337 render(preferences_view(
2338 &shell,
2339 &user,
2340 state.timezone,
2341 &state.default_locale,
2342 &offered_locales(&state.catalogs),
2343 sessions,
2344 None,
2345 ))
2346}
2347
2348#[derive(Deserialize)]
2349struct RevokeSessionForm {
2350 id: String,
2351}
2352
2353async fn session_revoke(
2355 State(state): State<AdminState>,
2356 Extension(user): Extension<AuthenticatedUser>,
2357 Extension(session): Extension<session::SessionHandle>,
2358 Form(form): Form<RevokeSessionForm>,
2359) -> Response {
2360 match state.auth.revoke_session(user.user.id, &form.id).await {
2361 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
2362 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
2363 Err(e) => {
2364 tracing::error!(error = %e, "revoking a session failed");
2365 session.push_flash(
2366 FlashLevel::Error,
2367 t!("That session could not be signed out."),
2368 );
2369 }
2370 }
2371 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2372}
2373
2374async fn session_revoke_others(
2376 State(state): State<AdminState>,
2377 Extension(user): Extension<AuthenticatedUser>,
2378 Extension(session): Extension<session::SessionHandle>,
2379 jar: CookieJar,
2380) -> Response {
2381 let token = jar
2382 .get(SESSION_COOKIE)
2383 .map(|c| c.value().to_string())
2384 .unwrap_or_default();
2385 match state.auth.sign_out_everywhere(user.user.id, &token).await {
2386 Ok(_) => session.push_flash(
2387 FlashLevel::Success,
2388 t!("Every other session was signed out."),
2389 ),
2390 Err(e) => {
2391 tracing::error!(error = %e, "signing out other sessions failed");
2392 session.push_flash(
2393 FlashLevel::Error,
2394 t!("Those sessions could not be signed out."),
2395 );
2396 }
2397 }
2398 (
2401 jar.remove(remember_removal(&state.admin_path)),
2402 Redirect::to(&format!("{}/preferences", state.admin_path)),
2403 )
2404 .into_response()
2405}
2406
2407#[derive(Deserialize)]
2408struct PreferencesForm {
2409 timezone: String,
2410 #[serde(default)]
2412 locale: String,
2413}
2414
2415async fn preferences_update(
2416 State(state): State<AdminState>,
2417 Extension(shell): Extension<Shell>,
2418 Extension(user): Extension<AuthenticatedUser>,
2419 Extension(session): Extension<session::SessionHandle>,
2420 jar: CookieJar,
2421 Form(form): Form<PreferencesForm>,
2422) -> Response {
2423 let offered = offered_locales(&state.catalogs);
2424 let tz = form.timezone.trim();
2426 let tz_stored = if tz.is_empty() {
2427 None
2428 } else if tz.parse::<Tz>().is_ok() {
2429 Some(tz)
2430 } else {
2431 return render(preferences_view(
2432 &shell,
2433 &user,
2434 state.timezone,
2435 &state.default_locale,
2436 &offered,
2437 session_rows(&state, &user, &shell, &jar).await,
2438 Some(t!("That is not a recognised timezone.")),
2439 ));
2440 };
2441 let loc = form.locale.trim();
2442 let loc_stored = if loc.is_empty() {
2443 None
2444 } else if offered.iter().any(|l| l == loc) {
2445 Some(loc)
2446 } else {
2447 return render(preferences_view(
2448 &shell,
2449 &user,
2450 state.timezone,
2451 &state.default_locale,
2452 &offered,
2453 session_rows(&state, &user, &shell, &jar).await,
2454 Some(t!("That is not a supported language.")),
2455 ));
2456 };
2457 if state
2458 .auth
2459 .set_user_timezone(user.user.id, tz_stored)
2460 .await
2461 .is_err()
2462 {
2463 return render_error();
2464 }
2465 match state.auth.set_user_locale(user.user.id, loc_stored).await {
2466 Ok(()) => {
2467 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
2468 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2469 }
2470 Err(_) => render_error(),
2471 }
2472}
2473
2474fn preferences_view(
2478 shell: &Shell,
2479 user: &AuthenticatedUser,
2480 default_tz: Tz,
2481 default_locale: &str,
2482 offered: &[String],
2483 sessions: Vec<SessionRow>,
2484 error: Option<Text>,
2485) -> PreferencesTemplate {
2486 let current = user.user.timezone.as_deref();
2487 let zones = TZ_VARIANTS
2488 .iter()
2489 .filter(|tz| zone_offered(tz.name()))
2490 .map(|tz| TzOption {
2491 name: tz.name().to_string(),
2492 selected: current == Some(tz.name()),
2493 })
2494 .collect();
2495 let current_locale = user.user.locale.as_deref();
2496 let locales = offered
2497 .iter()
2498 .map(|code| LocaleOption {
2499 name: locale_name(code),
2500 selected: current_locale == Some(code.as_str()),
2501 code: code.clone(),
2502 })
2503 .collect();
2504 PreferencesTemplate {
2505 shell: shell.clone(),
2506 zones,
2507 effective_tz: shell.tz.name().to_string(),
2508 default_tz: default_tz.name().to_string(),
2509 inherits: current.is_none(),
2510 locales,
2511 default_locale: locale_name(default_locale),
2512 inherits_locale: current_locale.is_none(),
2513 sessions,
2514 error: error.map(|e| shell.tt(&e)),
2515 }
2516}
2517
2518async fn session_rows(
2521 state: &AdminState,
2522 user: &AuthenticatedUser,
2523 shell: &Shell,
2524 jar: &CookieJar,
2525) -> Vec<SessionRow> {
2526 let token = jar
2527 .get(SESSION_COOKIE)
2528 .map(|c| c.value().to_string())
2529 .unwrap_or_default();
2530 let locale = list::date_locale(shell.locale());
2531 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
2532 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
2533 };
2534 match state.auth.list_sessions(user.user.id, &token).await {
2535 Ok(sessions) => sessions
2536 .into_iter()
2537 .map(|s| SessionRow {
2538 id: s.id,
2539 started: stamp(s.created_at),
2540 last_seen: stamp(s.last_seen_at),
2541 expires: stamp(s.expires_at),
2542 device: clientip::describe(s.user_agent.as_deref())
2543 .or(s.user_agent)
2544 .unwrap_or_else(|| "-".to_string()),
2545 ip_address: s.ip_address.unwrap_or_default(),
2546 current: s.current,
2547 })
2548 .collect(),
2549 Err(e) => {
2550 tracing::error!(error = %e, "listing sessions failed");
2551 Vec::new()
2552 }
2553 }
2554}
2555
2556fn builtin_resources() -> Vec<Resource> {
2558 vec![
2559 Resource {
2560 base_path: "/users".to_string(),
2561 nav_label: "Backend Users".into(),
2562 list: backend_users_list_config(),
2563 form: None,
2564 permission: Some("backend.manage_users".to_string()),
2565 },
2566 Resource {
2567 base_path: "/roles".to_string(),
2568 nav_label: "Roles".into(),
2569 list: roles_list_config(),
2570 form: None,
2573 permission: Some("backend.manage_roles".to_string()),
2574 },
2575 Resource {
2576 base_path: "/audit-log".to_string(),
2577 nav_label: "Audit Log".into(),
2578 list: audit_log_list_config(),
2579 form: None,
2581 permission: Some("backend.view_audit_log".to_string()),
2582 },
2583 ]
2584}
2585
2586fn builtin_settings() -> Vec<settings::SettingsItem> {
2590 vec![
2591 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
2592 .description("Manage backend administrator accounts.")
2593 .category("Users")
2594 .order(10)
2595 .icon("users")
2596 .permission("backend.manage_users")
2597 .link("/users"),
2598 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
2599 .description("Manage roles and their permissions.")
2600 .category("Users")
2601 .order(20)
2602 .icon("shield")
2603 .permission("backend.manage_roles")
2604 .link("/roles"),
2605 settings::brand::settings_item(),
2606 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
2607 .description("Enable or disable installed plugins.")
2608 .category("System")
2609 .order(10)
2610 .icon("plug")
2611 .permission(plugins::MANAGE_PERMISSION)
2612 .link("/plugins"),
2613 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
2614 .description("Review the record of administrative changes.")
2615 .category("System")
2616 .order(20)
2617 .icon("history")
2618 .permission("backend.view_audit_log")
2619 .link("/audit-log"),
2620 ]
2621}
2622
2623fn backend_users_list_config() -> list::ListConfig {
2624 list::ListConfig {
2625 entity: "backend_users".to_string(),
2626 title: "Backend Users".into(),
2627 columns: vec![
2628 list::ListColumn::new("username", "Username"),
2629 list::ListColumn::new("email", "Email"),
2630 list::ListColumn::new("first_name", "First name"),
2631 list::ListColumn::new("last_name", "Last name"),
2632 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
2633 list::ListColumn::new("is_active", "Active").yes_no(),
2634 list::ListColumn::new("created_at", "Created").datetime(),
2635 ],
2636 order_by: "created_at".to_string(),
2637 edit_base: Some("/users".to_string()),
2640 filters: vec![
2641 list::ListFilter::boolean("is_active", "Active"),
2642 list::ListFilter::boolean("is_superuser", "Superuser"),
2643 ],
2644 ..Default::default()
2648 }
2649}
2650
2651fn roles_list_config() -> list::ListConfig {
2652 list::ListConfig {
2653 entity: "backend_roles".to_string(),
2654 title: "Roles".into(),
2655 columns: vec![
2656 list::ListColumn::new("code", "Code"),
2657 list::ListColumn::new("name", "Name"),
2658 list::ListColumn::new("created_at", "Created").datetime(),
2659 ],
2660 order_by: "created_at".to_string(),
2661 edit_base: Some("/roles".to_string()),
2662 creatable: true,
2663 deletable: true,
2664 ..Default::default()
2665 }
2666}
2667
2668fn audit_log_list_config() -> list::ListConfig {
2671 list::ListConfig {
2672 entity: "backend_audit_log".to_string(),
2673 title: "Audit Log".into(),
2674 columns: vec![
2675 list::ListColumn::new("created_at", "When").datetime(),
2676 list::ListColumn::new("actor_username", "Operator"),
2677 list::ListColumn::new("action", "Action"),
2678 list::ListColumn::new("target_type", "Target"),
2679 list::ListColumn::new("target_id", "Target ID"),
2680 ],
2681 order_by: "created_at".to_string(),
2682 per_page: 50,
2683 ..Default::default()
2685 }
2686}
2687
2688#[derive(Template)]
2689#[template(path = "login.html")]
2690struct LoginTemplate {
2691 base: String,
2693 asset_urls: Arc<AssetUrls>,
2695 brand: String,
2696 error: Option<String>,
2697 i18n: Translator,
2700}
2701
2702fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
2705 match urls.get(key) {
2706 Some(path) => format!("{base}/assets/{path}"),
2707 None => {
2708 debug_assert!(false, "asset key `{key}` is not registered");
2709 format!("{base}/assets/{key}")
2710 }
2711 }
2712}
2713
2714impl LoginTemplate {
2715 fn asset(&self, key: &str) -> String {
2716 preauth_asset(&self.asset_urls, &self.base, key)
2717 }
2718 fn t(&self, source: &str) -> String {
2719 self.i18n.t(&Text::dynamic(source))
2720 }
2721 fn locale(&self) -> &str {
2722 self.i18n.locale()
2723 }
2724}
2725
2726#[derive(Template)]
2727#[template(path = "dashboard.html")]
2728struct DashboardTemplate {
2729 shell: Shell,
2730 username: String,
2731}
2732
2733#[derive(Template)]
2734#[template(path = "setup.html")]
2735struct SetupTemplate {
2736 base: String,
2738 asset_urls: Arc<AssetUrls>,
2740 brand: String,
2741 zones: Vec<TzOption>,
2742 error: Option<String>,
2743 i18n: Translator,
2745}
2746
2747impl SetupTemplate {
2748 fn asset(&self, key: &str) -> String {
2749 preauth_asset(&self.asset_urls, &self.base, key)
2750 }
2751 fn t(&self, source: &str) -> String {
2752 self.i18n.t(&Text::dynamic(source))
2753 }
2754 fn locale(&self) -> &str {
2755 self.i18n.locale()
2756 }
2757}
2758
2759#[derive(Template)]
2760#[template(path = "preferences.html")]
2761struct PreferencesTemplate {
2762 shell: Shell,
2763 zones: Vec<TzOption>,
2764 effective_tz: String,
2766 default_tz: String,
2768 inherits: bool,
2770 locales: Vec<LocaleOption>,
2771 default_locale: String,
2773 inherits_locale: bool,
2775 sessions: Vec<SessionRow>,
2777 error: Option<String>,
2778}
2779
2780struct SessionRow {
2782 id: String,
2783 started: String,
2784 last_seen: String,
2785 expires: String,
2786 device: String,
2789 ip_address: String,
2791 current: bool,
2792}
2793
2794struct TzOption {
2795 name: String,
2796 selected: bool,
2797}
2798
2799struct LocaleOption {
2800 code: String,
2802 name: String,
2804 selected: bool,
2805}
2806
2807#[derive(Clone)]
2808struct NavView {
2809 label: String,
2810 path: String,
2811 active: bool,
2812 icon: &'static str,
2815}
2816
2817pub(crate) fn render<T: Template>(template: T) -> Response {
2819 match template.render() {
2820 Ok(html) => Html(html).into_response(),
2821 Err(e) => AdminError::from(e).into_response(),
2823 }
2824}
2825
2826pub(crate) fn render_error() -> Response {
2827 error::masked_500()
2828}
2829
2830pub(crate) fn not_found() -> Response {
2831 AdminError::NotFound.into_response()
2832}
2833
2834pub(crate) fn forbidden() -> Response {
2835 AdminError::Forbidden.into_response()
2836}
2837
2838#[cfg(test)]
2839mod tests {
2840 use super::*;
2841
2842 fn write_form() -> form::FormConfig {
2843 form::FormConfig {
2844 entity: "widgets".to_string(),
2845 title: "Widgets".into(),
2846 base_path: "/widgets".to_string(),
2847 id_field: "id".to_string(),
2848 fields: vec![],
2849 persist: None,
2850 timestamps: false,
2851 }
2852 }
2853
2854 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
2855 Resource {
2856 base_path: "/widgets".to_string(),
2857 nav_label: "Widgets".into(),
2858 list: backend_users_list_config(),
2859 form,
2860 permission: permission.map(str::to_string),
2861 }
2862 }
2863
2864 #[test]
2865 #[should_panic(expected = "must declare a permission")]
2866 fn write_resource_without_permission_aborts_boot() {
2867 let resource = resource_with(Some(write_form()), None);
2870 let _ = mount_resource(
2871 &resource,
2872 &field::builtin_registry(),
2873 &persist::PersisterRegistry::new(),
2874 &[],
2875 );
2876 }
2877
2878 #[test]
2879 fn read_only_resource_may_omit_permission() {
2880 let resource = resource_with(None, None);
2882 let _ = mount_resource(
2883 &resource,
2884 &field::builtin_registry(),
2885 &persist::PersisterRegistry::new(),
2886 &[],
2887 );
2888 }
2889
2890 #[test]
2891 fn shell_localizes_through_the_translator_and_falls_back() {
2892 let shell = Shell::test();
2894 assert_eq!(shell.t("Save"), "Save");
2895 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
2896 }
2897
2898 #[test]
2899 fn shell_tf_interpolates_integer_arguments() {
2900 let shell = Shell::test();
2901 assert_eq!(
2902 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
2903 "Page 2 of 5"
2904 );
2905 }
2906
2907 #[test]
2908 fn display_tz_prefers_a_valid_operator_preference() {
2909 assert_eq!(
2910 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
2911 Tz::Asia__Kolkata
2912 );
2913 }
2914
2915 #[test]
2916 fn display_tz_falls_back_when_unset_or_invalid() {
2917 let default = Tz::Europe__London;
2918 assert_eq!(resolve_display_tz(None, default), default);
2920 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
2922 assert_eq!(resolve_display_tz(Some(""), default), default);
2923 }
2924
2925 #[test]
2926 fn accept_language_orders_by_weight_and_drops_wildcard() {
2927 assert_eq!(
2928 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
2929 vec!["en-US", "en", "kn"]
2930 );
2931 assert_eq!(
2933 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
2934 vec!["kn", "en"]
2935 );
2936 }
2937
2938 #[test]
2939 fn locale_chain_prefers_operator_then_header_then_default() {
2940 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
2942 assert_eq!(
2945 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
2946 vec!["kn", "ta", "hi", "en"]
2947 );
2948 assert_eq!(
2950 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
2951 vec!["kn", "en", "ta"]
2952 );
2953 assert_eq!(
2955 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
2956 vec!["kn", "en"]
2957 );
2958 }
2959
2960 #[test]
2961 fn locale_chain_skips_unserveable_and_defaults_to_en() {
2962 let loaded = ["hi".to_string()];
2964 assert_eq!(
2967 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
2968 vec!["en"]
2969 );
2970 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
2972 assert_eq!(
2974 resolve_locale_chain(Some("hi"), None, "en", &loaded),
2975 vec!["hi", "en"]
2976 );
2977 assert_eq!(
2979 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
2980 vec![laterite_core::PSEUDO_LOCALE, "en"]
2981 );
2982 }
2983
2984 #[test]
2985 fn page_assets_dedupe_preserve_order_and_classify() {
2986 let mut reg = AssetRegistry::new();
2987 reg.insert(
2988 "a.js",
2989 AdminAsset {
2990 mime: "text/javascript; charset=utf-8",
2991 bytes: b"//",
2992 },
2993 );
2994 reg.insert(
2995 "b.css",
2996 AdminAsset {
2997 mime: "text/css; charset=utf-8",
2998 bytes: b"body{}",
2999 },
3000 );
3001 let urls = asset_urls(®);
3004 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
3005 assert_eq!(assets.len(), 2);
3006 assert_eq!(
3007 assets[0].url,
3008 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
3009 );
3010 assert!(!assets[0].css);
3011 assert_eq!(
3012 assets[1].url,
3013 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
3014 );
3015 assert!(assets[1].css);
3016 assert_ne!(assets[0].url, assets[1].url);
3019 }
3020
3021 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
3025 laterite_core::testing::connect_test(&[]).await
3026 }
3027
3028 #[tokio::test]
3029 async fn builtin_migrations_create_the_admin_tables() {
3030 let (db, _guard) = empty_db().await;
3031 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
3032 .await
3033 .unwrap();
3034 for table in ["backend_users", "settings"] {
3038 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
3039 .fetch_optional(&db.pool)
3040 .await;
3041 assert!(
3042 probe.is_ok(),
3043 "{table} should exist after builtin_migrations"
3044 );
3045 }
3046 }
3047
3048 #[tokio::test]
3049 async fn brand_setting_overrides_config_and_blank_falls_back() {
3050 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
3051 let state = AdminState {
3052 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3053 db: db.clone(),
3054 nav: Arc::new(Vec::new()),
3055 settings: Arc::new(Vec::new()),
3056 permissions: Arc::new(builtin_permissions()),
3057 trusted_proxies: Arc::new(Vec::new()),
3058 admin_path: Arc::from("/admin"),
3059 secure_cookie: false,
3060 origin: Arc::from(""),
3061 timezone: Tz::UTC,
3062 default_locale: Arc::from("en"),
3063 catalogs: Arc::new(CatalogStore::default()),
3064 app_name: "Configured Name".to_string(),
3065 brand_cache: Arc::new(RwLock::new(None)),
3066 field_types: Arc::new(field::builtin_registry()),
3067 column_types: Arc::new(list::builtin_column_registry()),
3068 plugin_defined: Arc::new(laterite_core::Registry::new()),
3069 assets: Arc::new(builtin_assets()),
3070 asset_urls: Arc::new(asset_urls(&builtin_assets())),
3071 pickers: Arc::new(picker::PickerRegistry::new()),
3072 overrides: Arc::new(field::NoOverrides),
3073 };
3074
3075 assert_eq!(state.brand().await, "Configured Name");
3077
3078 settings::store::save(
3081 &db,
3082 &settings::BrandSetting {
3083 app_name: "Acme Corp".to_string(),
3084 },
3085 )
3086 .await
3087 .unwrap();
3088 state.invalidate_brand();
3089 assert_eq!(state.brand().await, "Acme Corp");
3090
3091 settings::store::save(
3093 &db,
3094 &settings::BrandSetting {
3095 app_name: " ".to_string(),
3096 },
3097 )
3098 .await
3099 .unwrap();
3100 state.invalidate_brand();
3101 assert_eq!(state.brand().await, "Configured Name");
3102 }
3103
3104 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
3105 settings::SettingsItem {
3106 code: code.to_string(),
3107 label: code.into(),
3108 description: String::new().into(),
3109 category: "General".into(),
3110 order: 1,
3111 icon: None,
3112 permission: permission.map(str::to_string),
3113 link: None,
3114 fields: Vec::new(),
3115 route: None,
3116 }
3117 }
3118
3119 #[test]
3120 fn settings_visibility_respects_permissions() {
3121 let items = vec![
3122 settings_item("public", None),
3123 settings_item("gated", Some("backend.manage_users")),
3124 ];
3125
3126 let none = PermissionSet::new(false, Vec::<String>::new());
3128 let codes: Vec<String> = visible_settings(&items, &none)
3129 .into_iter()
3130 .map(|i| i.code)
3131 .collect();
3132 assert_eq!(codes, ["public"]);
3133
3134 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
3136 assert_eq!(visible_settings(&items, &granted).len(), 2);
3137
3138 let superuser = PermissionSet::new(true, Vec::<String>::new());
3140 assert_eq!(visible_settings(&items, &superuser).len(), 2);
3141 }
3142
3143 #[test]
3144 fn context_sidebar_follows_settings_links() {
3145 let items = builtin_settings();
3146 let superuser = PermissionSet::new(true, Vec::<String>::new());
3147 let tr = Translator::new("en");
3148 let sidebar =
3149 |path: &str| resolve_nav_context(&[], &items, "/admin", &superuser, path, &tr).0;
3150 let active_path = |path: &str| -> Option<String> {
3151 sidebar(path)
3152 .into_iter()
3153 .flat_map(|g| g.items)
3154 .find(|i| i.active)
3155 .map(|i| i.path)
3156 };
3157
3158 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
3160 assert_eq!(
3161 active_path("/admin/roles/42/edit").as_deref(),
3162 Some("/admin/roles")
3163 );
3164 assert!(!sidebar("/admin/settings").is_empty());
3166 assert_eq!(active_path("/admin/settings"), None);
3167 assert_eq!(
3172 active_path("/admin/settings/branding").as_deref(),
3173 Some("/admin/settings/branding")
3174 );
3175 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
3178 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
3179 assert!(sidebar("/admin").is_empty());
3181 }
3182
3183 #[test]
3184 fn active_nav_lights_the_right_tab() {
3185 let nav = vec![
3186 NavLink {
3187 label: "Dashboard".into(),
3188 path: "/admin".to_string(),
3189 icon: Some("layout-dashboard"),
3190 },
3191 NavLink {
3192 label: "Pages".into(),
3193 path: "/admin/pages".to_string(),
3194 icon: None,
3195 },
3196 NavLink {
3197 label: "Settings".into(),
3198 path: "/admin/settings".to_string(),
3199 icon: Some("settings"),
3200 },
3201 ];
3202
3203 assert_eq!(
3205 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
3206 Some("/admin")
3207 );
3208 assert_eq!(
3210 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
3211 Some("/admin/pages")
3212 );
3213 assert_eq!(
3215 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
3216 None
3217 );
3218 assert_eq!(
3221 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
3222 None
3223 );
3224 assert_eq!(
3227 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
3228 Some("/admin/settings")
3229 );
3230 assert_eq!(
3231 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
3232 Some("/admin/settings")
3233 );
3234 }
3235}
3236
3237#[cfg(test)]
3238mod session_cookie_tests {
3239 use super::*;
3240
3241 #[test]
3245 fn the_session_cookie_never_outlives_the_browser() {
3246 let cookie = session_cookie("tok".into(), "/admin", false);
3247 assert_eq!(cookie.max_age(), None);
3248 assert!(cookie.http_only().unwrap_or(false));
3249 assert_eq!(cookie.path(), Some("/admin"));
3250 }
3251
3252 #[test]
3255 fn the_remember_cookie_carries_its_own_lifetime() {
3256 let credential = laterite_auth::RememberCredential {
3257 cookie: "sel:ver".into(),
3258 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
3259 };
3260 let cookie = remember_cookie(credential, "/admin", true);
3261 let age = cookie.max_age().expect("a remember cookie must persist");
3262 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
3264 assert!(cookie.http_only().unwrap_or(false));
3265 assert!(cookie.secure().unwrap_or(false));
3266 }
3267
3268 #[test]
3270 fn the_removal_matches_the_cookie_it_clears() {
3271 let removal = remember_removal("/admin");
3272 assert_eq!(removal.name(), REMEMBER_COOKIE);
3273 assert_eq!(removal.path(), Some("/admin"));
3274 }
3275}