1mod audit;
18pub mod bootstrap;
19mod bulk;
20mod clientip;
21pub mod descriptor;
22mod error;
23mod export;
24pub mod field;
25pub mod form;
26pub mod html;
27pub mod http_cache;
28mod icons;
29pub(crate) mod keyed;
30pub mod list;
31pub mod persist;
32pub mod picker;
33pub mod plugins;
34mod roles;
35mod routemap;
36pub mod routes;
37mod session;
38pub mod settings;
39mod sql;
40mod upload;
41mod users;
42
43pub use axum;
51
52pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
53pub use error::AdminError;
54pub use session::{FlashLevel, SessionHandle};
55pub use upload::VerifiedUpload;
56
57use std::collections::HashMap;
58use std::sync::{Arc, RwLock};
59
60use askama::Template;
61use axum::body::Body;
62use axum::extract::{Path, Query, Request, State};
63use axum::http::{header, HeaderMap};
64use axum::middleware::{self, Next};
65use axum::response::{Html, IntoResponse, Redirect, Response};
66use axum::routing::{any, get, post};
67use axum::{Extension, Form, Router};
68use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
69use chrono_tz::{Tz, TZ_VARIANTS};
70use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
71use laterite_core::{t, CatalogStore, Db, Text, Translator};
72use serde::{Deserialize, Serialize};
73
74pub trait AdminRegistry {
80 fn add_resource(&mut self, resource: Resource);
82 fn add_permission(&mut self, permission: Permission);
84 fn add_settings(&mut self, item: settings::SettingsItem);
86 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
88 fn add_persister(&mut self, persister: persist::PersisterReg);
90 fn add_field_type(&mut self, field_type: field::FieldTypeReg);
92 fn add_screen(&mut self, screen: routes::ScreenReg);
95 fn add_public_route(&mut self, route: routes::PublicRouteReg);
98}
99
100impl AdminRegistry for laterite_core::Registry {
101 fn add_resource(&mut self, resource: Resource) {
102 self.add(resource);
103 }
104 fn add_permission(&mut self, permission: Permission) {
105 self.add(permission);
106 }
107 fn add_settings(&mut self, item: settings::SettingsItem) {
108 self.add(item);
109 }
110 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
111 self.add(source);
112 }
113 fn add_screen(&mut self, screen: routes::ScreenReg) {
114 self.add(screen);
115 }
116 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
117 self.add(route);
118 }
119 fn add_persister(&mut self, persister: persist::PersisterReg) {
120 self.add(persister);
121 }
122 fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
123 self.add(field_type);
124 }
125}
126
127const SESSION_COOKIE: &str = "laterite_session";
128const REMEMBER_COOKIE: &str = "laterite_remember";
132
133pub(crate) const ICON_SPRITE: &str = "icons.svg";
136
137#[derive(Clone)]
139pub(crate) struct AdminState {
140 auth: AuthService,
141 db: Db,
142 nav: Arc<Vec<NavLink>>,
143 settings: Arc<Vec<settings::SettingsItem>>,
144 permissions: Arc<Vec<Permission>>,
145 admin_path: Arc<str>,
149 secure_cookie: bool,
150 trusted_proxies: Arc<Vec<ipnet::IpNet>>,
152 icons: Arc<laterite_core::icons::IconSet>,
154 sprite_url: Arc<str>,
156 origin: Arc<str>,
158 timezone: Tz,
159 default_locale: Arc<str>,
163 catalogs: Arc<CatalogStore>,
166 app_name: String,
169 brand_cache: Arc<RwLock<Option<String>>>,
173 asset_urls: Arc<AssetUrls>,
175 field_types: Arc<field::FieldRegistry>,
178 column_types: Arc<list::ColumnRegistry>,
181 plugin_defined: Arc<laterite_core::Registry>,
184 assets: Arc<AssetRegistry>,
186 pickers: Arc<picker::PickerRegistry>,
189 overrides: Arc<dyn field::OverrideResolver>,
193}
194
195impl AdminState {
196 #[cfg(test)]
197 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
198 Self {
199 auth,
200 db,
201 nav: Arc::new(Vec::new()),
202 settings: Arc::new(Vec::new()),
203 permissions: Arc::new(builtin_permissions()),
204 trusted_proxies: Arc::new(Vec::new()),
205 icons: Arc::new(laterite_core::icons::IconSet::new()),
206 sprite_url: Arc::from(""),
207 admin_path: Arc::from("/admin"),
208 secure_cookie: false,
209 origin: Arc::from(""),
210 timezone: Tz::UTC,
211 default_locale: Arc::from("en"),
212 catalogs: Arc::new(CatalogStore::default()),
213 app_name: "Laterite".to_string(),
214 brand_cache: Arc::new(RwLock::new(None)),
215 field_types: Arc::new(field::builtin_registry()),
216 column_types: Arc::new(list::builtin_column_registry()),
217 plugin_defined: Arc::new(laterite_core::Registry::new()),
218 assets: Arc::new(builtin_assets()),
219 asset_urls: Arc::new(asset_urls(&builtin_assets())),
220 pickers: Arc::new(picker::PickerRegistry::new()),
221 overrides: Arc::new(field::NoOverrides),
222 }
223 }
224
225 async fn brand(&self) -> String {
229 {
230 let cached = self.brand_cache.read().unwrap().clone();
231 if let Some(name) = cached {
232 return name;
233 }
234 }
235 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
236 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
237 _ => self.app_name.clone(),
238 };
239 *self.brand_cache.write().unwrap() = Some(resolved.clone());
240 resolved
241 }
242
243 fn invalidate_brand(&self) {
245 *self.brand_cache.write().unwrap() = None;
246 }
247}
248
249#[derive(Clone)]
255#[non_exhaustive]
256pub struct AdminConfig {
257 pub secure_cookie: bool,
260 pub trusted_proxies: Vec<String>,
264 pub timezone: String,
268 pub locale: String,
272 pub app_name: String,
276 pub path: String,
280 pub origin: String,
285}
286
287impl Default for AdminConfig {
288 fn default() -> Self {
289 Self {
290 secure_cookie: false,
291 trusted_proxies: Vec::new(),
292 timezone: "UTC".to_string(),
293 locale: "en".to_string(),
294 app_name: "Laterite".to_string(),
295 path: "/admin".to_string(),
296 origin: String::new(),
297 }
298 }
299}
300
301#[derive(Clone)]
302struct NavLink {
303 label: Text,
304 path: String,
305 icon: Option<&'static str>,
308}
309
310#[derive(Clone)]
315pub(crate) struct Shell {
316 pub(crate) base: String,
320 brand: String,
324 nav: Vec<NavView>,
325 full_name: String,
326 initial: String,
327 tz: Tz,
331 i18n: Translator,
335 sidebar: Vec<settings::CategoryView>,
339 pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
341 pub(crate) sprite_url: Arc<str>,
344 site_url: String,
348 asset_urls: Arc<AssetUrls>,
352 pub(crate) csrf_token: String,
356 pub(crate) flash: Vec<FlashLine>,
360 pub(crate) assets: Vec<PageAsset>,
364}
365
366#[derive(Clone)]
368pub(crate) struct PageAsset {
369 pub(crate) url: String,
370 pub(crate) css: bool,
371}
372
373#[derive(Clone)]
376pub(crate) struct FlashLine {
377 pub(crate) level: session::FlashLevel,
378 pub(crate) text: String,
379}
380
381impl Shell {
382 #[allow(clippy::too_many_arguments)]
383 fn new(
384 base: &str,
385 brand: String,
386 nav: &[NavLink],
387 user: &AuthenticatedUser,
388 default_tz: Tz,
389 sidebar: Vec<settings::CategoryView>,
390 active_nav: Option<&str>,
391 csrf_token: String,
392 flash: Vec<session::Flash>,
393 i18n: Translator,
394 asset_urls: Arc<AssetUrls>,
395 site_url: String,
396 icon_set: Arc<laterite_core::icons::IconSet>,
397 sprite_url: Arc<str>,
398 ) -> Self {
399 let full_name = user.user.full_name();
400 let initial = full_name
401 .chars()
402 .next()
403 .map(|c| c.to_uppercase().to_string())
404 .unwrap_or_else(|| "?".to_string());
405 let flash = flash
408 .into_iter()
409 .map(|f| FlashLine {
410 level: f.level,
411 text: i18n.t(&f.text),
412 })
413 .collect();
414 let nav = nav
415 .iter()
416 .map(|n| NavView {
417 label: i18n.t(&n.label),
418 path: n.path.clone(),
419 active: active_nav == Some(n.path.as_str()),
420 icon: n
421 .icon
422 .map(|name| icons::svg(&icon_set, &sprite_url, Some(name)))
423 .unwrap_or_default(),
424 })
425 .collect();
426 Shell {
427 asset_urls,
428 icon_set,
429 sprite_url,
430 site_url,
431 base: base.to_string(),
432 brand,
433 nav,
434 full_name,
435 initial,
436 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
437 i18n,
438 sidebar,
439 csrf_token,
440 flash,
441 assets: Vec::new(),
442 }
443 }
444
445 pub(crate) fn site_url(&self) -> &str {
447 &self.site_url
448 }
449
450 pub(crate) fn icon(&self, name: &str) -> String {
452 icons::svg(&self.icon_set, &self.sprite_url, Some(name))
453 }
454
455 pub(crate) fn asset(&self, key: &str) -> String {
460 match self.asset_urls.get(key) {
461 Some(path) => format!("{}/assets/{path}", self.base),
462 None => {
463 debug_assert!(false, "asset key `{key}` is not registered");
464 format!("{}/assets/{key}", self.base)
465 }
466 }
467 }
468
469 pub(crate) fn t(&self, source: &str) -> String {
472 self.i18n.t(&Text::dynamic(source))
473 }
474
475 pub(crate) fn tt(&self, text: &Text) -> String {
478 self.i18n.t(text)
479 }
480
481 pub(crate) fn i18n(&self) -> &Translator {
484 &self.i18n
485 }
486
487 pub(crate) fn locale(&self) -> &str {
489 self.i18n.locale()
490 }
491
492 pub(crate) fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
496 let mut text = Text::dynamic(source);
497 for (name, value) in args {
498 text = text.arg(*name, *value);
499 }
500 self.i18n.t(&text)
501 }
502
503 pub(crate) fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
506 let mut text = Text::dynamic(source);
507 for (name, value) in args {
508 text = text.arg(*name, *value);
509 }
510 self.i18n.t(&text)
511 }
512
513 #[cfg(test)]
514 pub(crate) fn test() -> Self {
515 Shell {
516 asset_urls: Arc::new(asset_urls(&builtin_assets())),
517 icon_set: Arc::new(laterite_core::icons::IconSet::new()),
518 sprite_url: Arc::from("/admin/assets/icons.svg"),
519 site_url: "http://localhost".to_string(),
520 base: "/admin".to_string(),
521 brand: "Laterite".to_string(),
522 nav: Vec::new(),
523 full_name: "Test Operator".to_string(),
524 initial: "T".to_string(),
525 tz: Tz::UTC,
526 i18n: Translator::new("en"),
527 sidebar: Vec::new(),
528 csrf_token: "test-csrf-token".to_string(),
529 flash: Vec::new(),
530 assets: Vec::new(),
531 }
532 }
533}
534
535fn settings_context(
542 visible: &[settings::SettingsItem],
543 admin_path: &str,
544 path: &str,
545) -> (bool, Option<String>) {
546 if path == format!("{admin_path}/settings") {
547 return (true, None);
548 }
549 let own = visible
555 .iter()
556 .filter(|i| i.link.is_none())
557 .find(|i| path == format!("{admin_path}{}", i.route()))
558 .map(|i| i.code.clone());
559 if own.is_some() {
560 return (true, own);
561 }
562 let linked = visible
563 .iter()
564 .filter_map(|i| {
565 i.link
566 .as_deref()
567 .map(|link| (format!("{admin_path}{link}"), &i.code))
568 })
569 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
570 .max_by_key(|(link, _)| link.len())
571 .map(|(_, code)| code.clone());
572 (linked.is_some(), linked)
573}
574
575fn active_nav_path(
583 nav: &[NavLink],
584 admin_path: &str,
585 in_settings_context: bool,
586 path: &str,
587) -> Option<String> {
588 if in_settings_context {
589 return Some(format!("{admin_path}/settings"));
590 }
591 nav.iter()
592 .filter(|n| {
593 path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
594 })
595 .max_by_key(|n| n.path.len())
596 .map(|n| n.path.clone())
597}
598
599fn resolve_nav_context(
604 nav: &[NavLink],
605 items: &[settings::SettingsItem],
606 admin_path: &str,
607 perms: &PermissionSet,
608 path: &str,
609 tr: &Translator,
610 icons: icons::Icons<'_>,
611) -> (Vec<settings::CategoryView>, Option<String>) {
612 let visible = visible_settings(items, perms);
613 let (in_context, active) = settings_context(&visible, admin_path, path);
614 let sidebar = if in_context {
615 settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons)
616 } else {
617 Vec::new()
618 };
619 let active_nav = active_nav_path(nav, admin_path, in_context, path);
620 (sidebar, active_nav)
621}
622
623fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
627 preference
628 .and_then(|name| name.parse::<Tz>().ok())
629 .unwrap_or(default_tz)
630}
631
632fn zone_offered(name: &str) -> bool {
634 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
635}
636
637fn base_lang(tag: &str) -> String {
640 tag.split(['-', '_'])
641 .next()
642 .unwrap_or(tag)
643 .to_ascii_lowercase()
644}
645
646fn is_serveable(base: &str, serveable: &[String]) -> bool {
652 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
653}
654
655fn default_locale(configured: &str, serveable: &[String]) -> String {
658 let base = base_lang(configured);
659 if is_serveable(&base, serveable) {
660 base
661 } else {
662 "en".to_string()
663 }
664}
665
666fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
670 let mut out = vec!["en".to_string()];
671 out.extend(catalogs.locales());
672 out
673}
674
675fn locale_name(code: &str) -> String {
680 if code == "en" {
681 "English".to_string()
682 } else {
683 code.to_string()
684 }
685}
686
687fn parse_accept_language(header: &str) -> Vec<String> {
690 let mut items: Vec<(f32, String)> = header
691 .split(',')
692 .filter_map(|part| {
693 let mut bits = part.split(';');
694 let tag = bits.next()?.trim();
695 if tag.is_empty() || tag == "*" {
696 return None;
697 }
698 let q = bits
699 .find_map(|p| p.trim().strip_prefix("q="))
700 .and_then(|q| q.parse::<f32>().ok())
701 .unwrap_or(1.0);
702 Some((q, tag.to_string()))
703 })
704 .collect();
705 items.sort_by(|a, b| b.0.total_cmp(&a.0));
707 items.into_iter().map(|(_, tag)| tag).collect()
708}
709
710fn resolve_locale_chain(
716 preference: Option<&str>,
717 accept_language: Option<&str>,
718 default: &str,
719 serveable: &[String],
720) -> Vec<String> {
721 let mut chain: Vec<String> = Vec::new();
722 let consider = |tag: &str, chain: &mut Vec<String>| {
723 let base = base_lang(tag);
724 if is_serveable(&base, serveable) && !chain.contains(&base) {
725 chain.push(base);
726 }
727 };
728 if let Some(pref) = preference {
729 consider(pref, &mut chain);
730 }
731 if let Some(header) = accept_language {
732 for tag in parse_accept_language(header) {
733 consider(&tag, &mut chain);
734 }
735 }
736 consider(default, &mut chain);
737 consider("en", &mut chain);
740 chain
741}
742
743fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
747 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
748 let serveable = state.catalogs.locales();
749 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
750 Translator::with_chain(chain, state.catalogs.clone())
751}
752
753#[derive(Serialize)]
761#[non_exhaustive]
762pub struct Resource {
763 pub base_path: String,
769 pub nav_label: Text,
771 pub list: list::ListConfig,
772 pub form: Option<form::FormConfig>,
773 pub permission: Option<String>,
780}
781
782#[derive(Clone, Serialize)]
787#[non_exhaustive]
788pub struct Permission {
789 pub code: String,
790 pub label: Text,
792 pub group: Text,
793 pub default_roles: Vec<String>,
796 pub renamed_from: Vec<String>,
799}
800
801pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
802
803impl Permission {
804 pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
807 Self {
808 code: code.into(),
809 label: label.into(),
810 group: group.into(),
811 default_roles: Vec::new(),
812 renamed_from: Vec::new(),
813 }
814 }
815
816 pub fn renamed_from<I, S>(mut self, codes: I) -> Self
822 where
823 I: IntoIterator<Item = S>,
824 S: Into<String>,
825 {
826 self.renamed_from = codes.into_iter().map(Into::into).collect();
827 self
828 }
829
830 pub fn roles<I, S>(mut self, roles: I) -> Self
833 where
834 I: IntoIterator<Item = S>,
835 S: Into<String>,
836 {
837 self.default_roles = roles.into_iter().map(Into::into).collect();
838 self
839 }
840}
841
842pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
848 let permissions: Vec<Permission> = builtin_permissions()
849 .into_iter()
850 .chain(contributed.iter().cloned())
851 .collect();
852 let mut out: Vec<(String, String)> = Vec::new();
853 for permission in &permissions {
854 for old in &permission.renamed_from {
855 if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
856 panic!(
857 "permissions `{taken}` and `{}` both claim the old code `{old}`",
858 permission.code
859 );
860 }
861 if permissions.iter().any(|p| &p.code == old) {
862 panic!(
863 "permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
864 permission.code
865 );
866 }
867 out.push((old.clone(), permission.code.clone()));
868 }
869 }
870 out
871}
872
873pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
879 let permissions: Vec<Permission> = builtin_permissions()
882 .into_iter()
883 .chain(contributed.iter().cloned())
884 .collect();
885 let held = |role: &str| -> Vec<String> {
886 permissions
887 .iter()
888 .filter(|p| {
889 if p.default_roles.is_empty() {
890 role == ROLE_ADMIN
891 } else {
892 p.default_roles.iter().any(|r| r == role)
893 }
894 })
895 .map(|p| p.code.clone())
896 .collect()
897 };
898 vec![
899 laterite_auth::store::SystemRole {
900 code: ROLE_ADMIN,
901 name: "Administrator",
902 description: "Administers the panel itself: operators, roles and settings.",
903 permissions: held(ROLE_ADMIN),
904 },
905 laterite_auth::store::SystemRole {
906 code: ROLE_EDITOR,
907 name: "Editor",
908 description: "Works with the content the application declares.",
909 permissions: held(ROLE_EDITOR),
910 },
911 ]
912}
913
914impl Resource {
915 pub fn new(
920 base_path: impl Into<String>,
921 nav_label: impl Into<Text>,
922 list: list::ListConfig,
923 ) -> Self {
924 Self {
925 base_path: base_path.into(),
926 nav_label: nav_label.into(),
927 list,
928 form: None,
929 permission: None,
930 }
931 }
932
933 pub fn form(mut self, form: form::FormConfig) -> Self {
936 self.form = Some(form);
937 self
938 }
939
940 pub fn permission(mut self, permission: impl Into<String>) -> Self {
942 self.permission = Some(permission.into());
943 self
944 }
945}
946
947struct SystemRoleGuard;
953
954#[laterite_core::strata::async_trait]
955impl laterite_core::ModelListener for SystemRoleGuard {
956 async fn before_delete(
957 &self,
958 cx: &mut laterite_core::SaveCx<'_>,
959 rec: &laterite_core::Record,
960 ) -> Result<(), Text> {
961 let Some(id) = rec.id() else {
962 return Ok(());
963 };
964 let backend = cx.backend();
966 match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
967 Ok(true) => Err(t!(
968 "The framework maintains this role; it cannot be deleted."
969 )),
970 Err(_) => Err(t!(
972 "Could not check whether this role is maintained by the framework."
973 )),
974 Ok(false) => Ok(()),
975 }
976 }
977}
978
979fn builtin_permissions() -> Vec<Permission> {
982 vec![
985 Permission::new("backend.manage_users", "Manage backend users", "Backend"),
986 Permission::new("backend.manage_roles", "Manage roles", "Backend"),
987 Permission::new("backend.manage_branding", "Manage branding", "Backend"),
988 Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
989 Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
990 ]
991}
992
993pub fn descriptor_sources() -> Vec<String> {
999 let mut out = Vec::new();
1000 out.extend(laterite_core::collect_sources(&builtin_resources()));
1001 out.extend(laterite_core::collect_sources(&builtin_settings()));
1002 out.extend(laterite_core::collect_sources(&builtin_permissions()));
1003 out.sort();
1004 out.dedup();
1005 out
1006}
1007
1008pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
1023 builtin_modules().iter().map(|m| m.migrations()).collect()
1024}
1025
1026pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
1030 vec![
1031 Box::new(laterite_auth::AuthModule),
1032 Box::new(settings::SettingsModule),
1033 Box::new(plugins::PluginsModule),
1034 ]
1035}
1036
1037#[derive(Default)]
1061pub struct Contributions {
1062 pub resources: Vec<Resource>,
1064 pub settings: Vec<settings::SettingsItem>,
1066 pub permissions: Vec<Permission>,
1068 pub picker_sources: Vec<picker::PickerSourceReg>,
1070 pub persisters: Vec<persist::PersisterReg>,
1072 pub listeners: Vec<laterite_core::ModelListenerReg>,
1074 pub field_types: Vec<field::FieldTypeReg>,
1078 pub column_types: Vec<list::ColumnTypeReg>,
1079 pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
1082 pub screens: Vec<routes::ScreenReg>,
1084 pub public_routes: Vec<routes::PublicRouteReg>,
1086 pub plugin_defined: Arc<laterite_core::Registry>,
1091}
1092
1093pub fn router(
1094 auth: AuthService,
1095 db: Db,
1096 contributions: Contributions,
1097 config: AdminConfig,
1098 catalogs: Arc<CatalogStore>,
1099) -> Router {
1100 let Contributions {
1101 resources: app_resources,
1102 settings: app_settings,
1103 permissions: app_permissions,
1104 picker_sources: app_picker_sources,
1105 persisters: app_persisters,
1106 listeners: app_listeners,
1107 field_types: app_field_types,
1108 column_types: app_column_types,
1109 icons: app_icons,
1110 screens: app_screens,
1111 public_routes: app_public,
1112 plugin_defined,
1113 } = contributions;
1114 let admin_path = normalize_path(&config.path);
1115
1116 let mut resources = builtin_resources();
1117 let mut app_resources = app_resources;
1118 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
1122 prefix_resource(&admin_path, resource);
1123 }
1124
1125 let mut settings = builtin_settings();
1126 settings.extend(app_settings);
1127 let mut app_listeners = app_listeners;
1130 app_listeners.insert(
1131 0,
1132 laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
1133 );
1134
1135 let mut permissions = builtin_permissions();
1136 permissions.extend(app_permissions);
1137 let permission_codes: std::collections::HashSet<String> =
1139 permissions.iter().map(|p| p.code.clone()).collect();
1140
1141 let mut nav = vec![NavLink {
1145 label: "Dashboard".into(),
1146 path: admin_path.clone(),
1147 icon: Some("layout-dashboard"),
1148 }];
1149 for resource in &app_resources {
1150 nav.push(NavLink {
1151 label: resource.nav_label.clone(),
1152 path: resource.base_path.clone(),
1153 icon: None,
1154 });
1155 }
1156 for reg in &app_screens {
1159 if let Some(label) = ®.nav_label {
1160 nav.push(NavLink {
1161 label: label.clone(),
1162 path: format!("{admin_path}{}", reg.base_path),
1163 icon: None,
1164 });
1165 }
1166 }
1167 nav.push(NavLink {
1168 label: "Settings".into(),
1169 path: format!("{admin_path}/settings"),
1170 icon: Some("settings"),
1171 });
1172 resources.extend(app_resources);
1173
1174 let app_name = if config.app_name.trim().is_empty() {
1175 "Laterite".to_string()
1176 } else {
1177 config.app_name.clone()
1178 };
1179
1180 let mut pickers = picker::PickerRegistry::new();
1183 for reg in app_picker_sources {
1184 let name = reg.name.clone();
1185 if !field::is_name(&name, true) {
1186 panic!("invalid picker source name `{name}`");
1187 }
1188 if pickers.insert(name.clone(), reg).is_some() {
1189 panic!("duplicate picker source `{name}`");
1190 }
1191 }
1192
1193 let pickers = Arc::new(pickers);
1197 let mut field_types = field::builtin_registry();
1198 field_types.insert(
1199 "reference".to_string(),
1200 Arc::new(field::RefPickerField::new(pickers.clone())),
1201 );
1202
1203 let mut persisters = persist::PersisterRegistry::new();
1206 for reg in app_persisters {
1207 let name = reg.name.clone();
1208 if !field::is_name(&name, true) {
1209 panic!("invalid persister name `{name}`");
1210 }
1211 if persisters.insert(name.clone(), reg.persister).is_some() {
1212 panic!("duplicate persister `{name}`");
1213 }
1214 }
1215
1216 for reg in app_field_types {
1222 let name = reg.name().to_string();
1223 if !field::is_name(&name, true) {
1224 panic!("invalid field type name `{name}`");
1225 }
1226 if field_types.insert(name.clone(), reg.field_type).is_some() {
1227 panic!("duplicate field type `{name}`");
1228 }
1229 }
1230
1231 let mut icon_set = laterite_core::icons::IconSet::new();
1235 for (owner, reg) in app_icons {
1236 if let Err(e) = icon_set.add(owner.as_str(), ®) {
1237 panic!("module `{owner}` contributed an invalid icon: {e}");
1238 }
1239 }
1240
1241 let mut assets = builtin_assets();
1244 assets.insert(
1245 ICON_SPRITE,
1246 AdminAsset {
1247 mime: "image/svg+xml",
1248 bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
1249 },
1250 );
1251
1252 for item in &settings {
1256 icons::require(
1257 &icon_set,
1258 &format!("settings item `{}`", item.code),
1259 item.icon.as_deref(),
1260 );
1261 }
1262 for link in &nav {
1263 icons::require(&icon_set, &format!("menu entry `{}`", link.path), link.icon);
1264 }
1265
1266 let mut column_types = list::builtin_column_registry();
1270 for reg in app_column_types {
1271 let name = reg.name().to_string();
1272 if !field::is_name(&name, true) {
1273 panic!("invalid column type name `{name}`");
1274 }
1275 if column_types.insert(name.clone(), reg.column_type).is_some() {
1276 panic!("duplicate column type `{name}`");
1277 }
1278 }
1279
1280 let settings = Arc::new(settings);
1281 let asset_urls_arc = Arc::new(asset_urls(&assets));
1283 let sprite_url: Arc<str> = Arc::from(
1284 asset_urls_arc
1285 .get(ICON_SPRITE)
1286 .map(|p| format!("{admin_path}/assets/{p}"))
1287 .unwrap_or_default()
1288 .as_str(),
1289 );
1290 let icons_arc = Arc::new(icon_set);
1291 let state = AdminState {
1292 auth,
1293 db,
1294 nav: Arc::new(nav),
1295 settings: settings.clone(),
1296 permissions: Arc::new(permissions),
1297 admin_path: Arc::from(admin_path.as_str()),
1298 secure_cookie: config.secure_cookie,
1299 trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
1300 icons: icons_arc.clone(),
1301 sprite_url: sprite_url.clone(),
1302 origin: Arc::from(config.origin.trim_end_matches('/')),
1303 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
1304 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
1305 catalogs,
1306 app_name,
1307 brand_cache: Arc::new(RwLock::new(None)),
1308 field_types: Arc::new(field_types),
1309 column_types: Arc::new(column_types),
1310 plugin_defined,
1311 assets: Arc::new(assets),
1312 asset_urls: asset_urls_arc.clone(),
1313 pickers,
1314 overrides: Arc::new(field::NoOverrides),
1315 };
1316
1317 let mut protected = Router::new().route(&admin_path, get(dashboard));
1318 for resource in &resources {
1319 protected = protected.merge(mount_resource(
1320 resource,
1321 &state.field_types,
1322 &state.column_types,
1323 &permission_codes,
1324 &persisters,
1325 &app_listeners,
1326 ));
1327 }
1328 for reg in &app_screens {
1332 let base = format!("{admin_path}{}", reg.base_path);
1333 let ctx = routes::RouteCtx::new(
1334 state.db.clone(),
1335 &base,
1336 &admin_path,
1337 &state.origin,
1338 state.plugin_defined.clone(),
1339 );
1340 protected = protected.merge(guard_with_permission(
1341 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
1342 ®.permission,
1343 ));
1344 }
1345
1346 protected = protected.merge(guard_with_permission(
1349 Router::new()
1350 .route(
1351 &format!("{admin_path}/roles/new"),
1352 get(roles::new_form).post(roles::create),
1353 )
1354 .route(
1355 &format!("{admin_path}/roles/{{id}}/edit"),
1356 get(roles::edit_form).post(roles::update),
1357 ),
1358 "backend.manage_roles",
1359 ));
1360 protected = protected.merge(guard_with_permission(
1363 Router::new()
1364 .route(
1365 &format!("{admin_path}/users/{{id}}/edit"),
1366 get(users::edit_form).post(users::update),
1367 )
1368 .route(
1369 &format!("{admin_path}/users/{{id}}/active"),
1370 post(users::set_active),
1371 ),
1372 "backend.manage_users",
1373 ));
1374 protected = protected.merge(guard_with_permission(
1377 Router::new()
1378 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1379 .route(
1380 &format!("{admin_path}/plugins/toggle"),
1381 post(plugins::toggle),
1382 ),
1383 plugins::MANAGE_PERMISSION,
1384 ));
1385 protected = protected
1386 .route(&format!("{admin_path}/settings"), get(settings_index))
1387 .route(
1388 &format!("{admin_path}/preferences"),
1389 get(preferences_form).post(preferences_update),
1390 )
1391 .route(
1392 &format!("{admin_path}/preferences/password"),
1393 post(password_update),
1394 )
1395 .route(
1396 &format!("{admin_path}/preferences/sessions/revoke"),
1397 post(session_revoke),
1398 )
1399 .route(
1400 &format!("{admin_path}/preferences/sessions/revoke-others"),
1401 post(session_revoke_others),
1402 )
1403 .route(
1406 &format!("{admin_path}/pickers/{{source}}/search"),
1407 any(picker::search),
1408 )
1409 .route(
1410 &format!("{admin_path}/pickers/{{source}}/resolve"),
1411 any(picker::resolve),
1412 )
1413 .route(&format!("{admin_path}/logout"), post(logout));
1414
1415 for item in settings.iter().filter(|i| i.link.is_none()) {
1419 let path = format!("{admin_path}{}", item.route());
1420 let code = item.code.clone();
1421 let edit_code = code.clone();
1422 protected = protected.route(
1423 &path,
1424 get(
1425 move |State(state): State<AdminState>,
1426 Extension(shell): Extension<Shell>,
1427 Extension(user): Extension<AuthenticatedUser>| {
1428 let code = edit_code.clone();
1429 async move { settings_edit(state, shell, user, code).await }
1430 },
1431 )
1432 .post(
1433 move |State(state): State<AdminState>,
1434 Extension(shell): Extension<Shell>,
1435 Extension(user): Extension<AuthenticatedUser>,
1436 Extension(session): Extension<session::SessionHandle>,
1437 Form(data): Form<HashMap<String, String>>| {
1438 let code = code.clone();
1439 async move { settings_update(state, shell, user, session, code, data).await }
1440 },
1441 ),
1442 );
1443 }
1444
1445 protected
1446 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1447 .route(
1451 &format!("{admin_path}/login"),
1452 get(login_form).post(login_submit),
1453 )
1454 .route(
1455 &format!("{admin_path}/setup"),
1456 get(setup_form).post(setup_submit),
1457 )
1458 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1459 .merge(mount_public(
1463 &app_public,
1464 &state.db,
1465 &admin_path,
1466 &state.origin,
1467 &state.plugin_defined,
1468 ))
1469 .fallback(not_found_fallback)
1471 .layer(middleware::from_fn_with_state(
1474 state.clone(),
1475 enforce_origin,
1476 ))
1477 .layer(middleware::from_fn_with_state(
1480 state.clone(),
1481 capture_client,
1482 ))
1483 .layer(
1488 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1489 header::CACHE_CONTROL,
1490 axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
1491 ),
1492 )
1493 .layer(
1497 tower_http::set_header::SetResponseHeaderLayer::if_not_present(
1498 header::VARY,
1499 axum::http::HeaderValue::from_static("HX-Request"),
1500 ),
1501 )
1502 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1503 handle_panic,
1504 ))
1505 .with_state(state)
1506}
1507
1508async fn not_found_fallback() -> AdminError {
1510 AdminError::NotFound
1511}
1512
1513fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1515 let cause = err
1516 .downcast_ref::<&str>()
1517 .map(|s| s.to_string())
1518 .or_else(|| err.downcast_ref::<String>().cloned())
1519 .unwrap_or_else(|| "unknown panic".to_string());
1520 tracing::error!(panic = cause, "admin handler panicked");
1521 error::masked_500()
1522}
1523
1524pub fn normalize_path(path: &str) -> String {
1529 let trimmed = path.trim().trim_matches('/');
1530 if trimmed.is_empty() {
1531 "/admin".to_string()
1532 } else {
1533 format!("/{trimmed}")
1534 }
1535}
1536
1537fn mount_public(
1541 regs: &[routes::PublicRouteReg],
1542 db: &Db,
1543 admin_path: &str,
1544 origin: &str,
1545 plugin_defined: &Arc<laterite_core::Registry>,
1546) -> Router<AdminState> {
1547 let mut router = Router::new();
1548 for reg in regs {
1549 let ctx = routes::RouteCtx::new(
1550 db.clone(),
1551 ®.path,
1552 admin_path,
1553 origin,
1554 plugin_defined.clone(),
1555 );
1556 router = router.nest_service(
1561 ®.path,
1562 reg.route.mount(&ctx).fallback(not_found_fallback),
1563 );
1564 }
1565 router
1566}
1567
1568pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1571 let old = resource.base_path.clone();
1572 resource.base_path = base.to_string();
1573 if let Some(edit_base) = &mut resource.list.edit_base {
1574 if edit_base == &old {
1575 *edit_base = base.to_string();
1576 }
1577 }
1578 if let Some(form) = &mut resource.form {
1579 if form.base_path == old {
1580 form.base_path = base.to_string();
1581 }
1582 }
1583}
1584
1585fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1586 resource.base_path = format!("{admin_path}{}", resource.base_path);
1587 if let Some(edit_base) = &mut resource.list.edit_base {
1588 *edit_base = format!("{admin_path}{edit_base}");
1589 }
1590 if let Some(form) = &mut resource.form {
1591 form.base_path = format!("{admin_path}{}", form.base_path);
1592 }
1593}
1594
1595pub(crate) struct AdminAsset {
1597 pub mime: &'static str,
1598 pub bytes: std::borrow::Cow<'static, [u8]>,
1602}
1603
1604pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1607
1608pub(crate) type AssetUrls = HashMap<&'static str, String>;
1612
1613pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
1615 registry
1616 .iter()
1617 .map(|(&key, asset)| {
1618 (
1619 key,
1620 http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
1621 )
1622 })
1623 .collect()
1624}
1625
1626pub(crate) fn page_assets(
1633 keys: &[&str],
1634 base: &str,
1635 registry: &AssetRegistry,
1636 urls: &AssetUrls,
1637) -> Vec<PageAsset> {
1638 let mut seen = std::collections::HashSet::new();
1639 let mut out = Vec::new();
1640 for &key in keys {
1641 if !seen.insert(key) {
1642 continue;
1643 }
1644 match (registry.get(key), urls.get(key)) {
1645 (Some(asset), Some(path)) => out.push(PageAsset {
1646 url: format!("{base}/assets/{path}"),
1647 css: asset.mime.contains("css"),
1648 }),
1649 _ => debug_assert!(false, "asset key `{key}` is not registered"),
1650 }
1651 }
1652 out
1653}
1654
1655pub(crate) fn builtin_assets() -> AssetRegistry {
1664 let font = |bytes: &'static [u8]| AdminAsset {
1665 mime: "font/woff2",
1666 bytes: std::borrow::Cow::Borrowed(bytes),
1667 };
1668 HashMap::from([
1669 (
1670 "laterite.css",
1671 AdminAsset {
1672 mime: "text/css; charset=utf-8",
1673 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
1674 },
1675 ),
1676 (
1677 "laterite.js",
1678 AdminAsset {
1679 mime: "text/javascript; charset=utf-8",
1680 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
1681 },
1682 ),
1683 (
1684 "vendor/htmx.min.js",
1685 AdminAsset {
1686 mime: "text/javascript; charset=utf-8",
1687 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
1688 },
1689 ),
1690 (
1691 "fields/ref-picker.js",
1692 AdminAsset {
1693 mime: "text/javascript; charset=utf-8",
1694 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
1695 },
1696 ),
1697 (
1698 "fields/ref-picker.css",
1699 AdminAsset {
1700 mime: "text/css; charset=utf-8",
1701 bytes: std::borrow::Cow::Borrowed(include_bytes!(
1702 "../assets/fields/ref-picker.css"
1703 )),
1704 },
1705 ),
1706 (
1707 "mark.svg",
1708 AdminAsset {
1709 mime: "image/svg+xml",
1710 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
1711 },
1712 ),
1713 (
1714 "mark.png",
1715 AdminAsset {
1716 mime: "image/png",
1717 bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.png")),
1718 },
1719 ),
1720 (
1721 "fonts/space-grotesk-500.woff2",
1722 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1723 ),
1724 (
1725 "fonts/space-grotesk-600.woff2",
1726 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1727 ),
1728 (
1729 "fonts/space-grotesk-700.woff2",
1730 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1731 ),
1732 (
1733 "fonts/ibm-plex-sans-400.woff2",
1734 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1735 ),
1736 (
1737 "fonts/ibm-plex-sans-600.woff2",
1738 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1739 ),
1740 (
1741 "fonts/ibm-plex-mono-400.woff2",
1742 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1743 ),
1744 (
1745 "fonts/ibm-plex-mono-600.woff2",
1746 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1747 ),
1748 ])
1749}
1750
1751async fn serve_asset(
1753 State(state): State<AdminState>,
1754 headers: HeaderMap,
1755 Path(path): Path<String>,
1756) -> Response {
1757 let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
1761 let Some(asset) = state.assets.get(key.as_str()) else {
1762 return not_found();
1763 };
1764 let policy = if fingerprinted {
1765 http_cache::IMMUTABLE
1766 } else {
1767 http_cache::REVALIDATE
1768 };
1769 http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
1770}
1771
1772fn mount_resource(
1777 resource: &Resource,
1778 field_types: &field::FieldRegistry,
1779 column_types: &list::ColumnRegistry,
1780 permission_codes: &std::collections::HashSet<String>,
1781 persisters: &persist::PersisterRegistry,
1782 listeners: &[laterite_core::ModelListenerReg],
1783) -> Router<AdminState> {
1784 list::check(&resource.list, column_types, permission_codes)
1788 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
1789 if resource.form.is_some() && resource.permission.is_none() {
1794 panic!(
1795 "admin resource `{}` has a create/edit form but no permission; a write \
1796 resource must declare a permission",
1797 resource.base_path
1798 );
1799 }
1800 let base = resource.base_path.clone();
1801 let list_path = base.clone();
1802 let list_cfg = resource.list.clone();
1803 let mut router = Router::new().route(
1804 &base,
1805 get(
1806 move |State(state): State<AdminState>,
1807 Extension(shell): Extension<Shell>,
1808 Query(params): Query<list::ListParams>,
1809 Query(raw): Query<std::collections::HashMap<String, String>>,
1810 Extension(user): Extension<AuthenticatedUser>,
1811 headers: axum::http::HeaderMap| {
1812 let cfg = list_cfg.clone();
1813 let path = list_path.clone();
1814 async move {
1815 list::handle(&state, &cfg, &path, params, &raw, &user, shell, &headers).await
1816 }
1817 },
1818 ),
1819 );
1820
1821 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
1824 if resource.list.exportable {
1825 router = router.route(
1826 &format!("{base}/export"),
1827 get(
1828 move |state: State<AdminState>,
1829 Query(raw): Query<std::collections::HashMap<String, String>>,
1830 Extension(user): Extension<AuthenticatedUser>,
1831 Extension(shell): Extension<Shell>,
1832 Extension(session): Extension<session::SessionHandle>| {
1833 let cfg = export_cfg.clone();
1834 let path = export_path.clone();
1835 async move {
1836 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
1837 }
1838 },
1839 ),
1840 );
1841 }
1842
1843 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
1844 router = router.route(
1845 &format!("{base}/columns"),
1846 post(
1847 move |State(state): State<AdminState>,
1848 Extension(user): Extension<AuthenticatedUser>,
1849 Extension(session): Extension<session::SessionHandle>,
1850 headers: axum::http::HeaderMap,
1851 Form(pairs): Form<Vec<(String, String)>>| {
1852 let cfg = columns_cfg.clone();
1853 let path = columns_path.clone();
1854 async move {
1855 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
1856 }
1857 },
1858 ),
1859 );
1860
1861 if resource.list.deletable {
1862 let persister: Arc<dyn persist::Persister> =
1866 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
1867 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
1868 panic!(
1869 "admin resource `{}`: unregistered persister `{key}`",
1870 resource.base_path
1871 )
1872 }),
1873 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
1874 };
1875 let ctx = Arc::new(bulk::BulkContext {
1876 base_path: resource.base_path.clone(),
1877 entity: resource.list.entity.clone(),
1878 persister,
1879 listeners: listeners
1880 .iter()
1881 .filter(|reg| reg.matches(&resource.list.entity))
1882 .map(|reg| reg.listener.clone())
1883 .collect(),
1884 });
1885 router =
1886 router.route(
1887 &format!("{base}/delete"),
1888 post(
1889 move |state: State<AdminState>,
1890 user: Extension<AuthenticatedUser>,
1891 session: Extension<session::SessionHandle>,
1892 headers: axum::http::HeaderMap,
1893 form: Form<Vec<(String, String)>>| {
1894 let ctx = ctx.clone();
1895 async move {
1896 bulk::delete_handler(state, user, session, ctx, headers, form).await
1897 }
1898 },
1899 ),
1900 );
1901 }
1902
1903 if let Some(form_cfg) = resource.form.clone() {
1904 let prepared = Arc::new(
1907 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
1908 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1909 );
1910 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1911 router = router.route(
1912 &format!("{base}/new"),
1913 get(
1914 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1915 let pf = new_pf.clone();
1916 async move { form::new_form(&state, &pf, shell) }
1917 },
1918 )
1919 .post(
1920 move |State(state): State<AdminState>,
1921 Extension(shell): Extension<Shell>,
1922 Extension(user): Extension<AuthenticatedUser>,
1923 Extension(session): Extension<session::SessionHandle>,
1924 headers: axum::http::HeaderMap,
1925 Form(data): Form<HashMap<String, String>>| {
1926 let pf = create_pf.clone();
1927 async move {
1928 form::create(&state, &pf, data, shell, &user, &session, &headers).await
1929 }
1930 },
1931 ),
1932 );
1933
1934 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1935 router = router.route(
1936 &format!("{base}/{{id}}/edit"),
1937 get(
1938 move |State(state): State<AdminState>,
1939 Extension(shell): Extension<Shell>,
1940 Path(id): Path<String>| {
1941 let pf = edit_pf.clone();
1942 async move { form::edit_form(&state, &pf, id, shell).await }
1943 },
1944 )
1945 .post(
1946 move |State(state): State<AdminState>,
1947 Extension(shell): Extension<Shell>,
1948 Extension(user): Extension<AuthenticatedUser>,
1949 Extension(session): Extension<session::SessionHandle>,
1950 Path(id): Path<String>,
1951 headers: axum::http::HeaderMap,
1952 Form(data): Form<HashMap<String, String>>| {
1953 let pf = update_pf.clone();
1954 async move {
1955 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
1956 }
1957 },
1958 ),
1959 );
1960 }
1961
1962 if let Some(permission) = &resource.permission {
1964 router = guard_with_permission(router, permission);
1965 }
1966 router
1967}
1968
1969fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
1974 let needed: Arc<str> = Arc::from(permission);
1975 router.route_layer(middleware::from_fn(
1976 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
1977 let needed = needed.clone();
1978 async move {
1979 if user.allows(&needed) {
1980 next.run(req).await
1981 } else {
1982 forbidden()
1983 }
1984 }
1985 },
1986 ))
1987}
1988
1989async fn require_auth(
1995 State(state): State<AdminState>,
1996 jar: CookieJar,
1997 request: Request,
1998 next: Next,
1999) -> Response {
2000 let login = format!("{}/login", state.admin_path);
2001 let client_ctx = request
2002 .extensions()
2003 .get::<RequestContext>()
2004 .cloned()
2005 .unwrap_or_default();
2006 let mut ended: Option<laterite_auth::SessionEnd> = None;
2011 let live = match jar.get(SESSION_COOKIE) {
2012 Some(cookie) => {
2013 let token = cookie.value().to_string();
2014 match state.auth.resolve_session(&token).await {
2015 Ok(resolved) => Some((token, resolved)),
2016 Err(_) => {
2017 ended = state.auth.session_end(&token).await.ok().flatten();
2018 None
2019 }
2020 }
2021 }
2022 None => None,
2023 };
2024 let htmx = request.headers().contains_key("hx-request");
2025 let (token, resolved, recalled) = match live {
2026 Some((token, resolved)) => (token, resolved, None),
2027 None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
2030 return signed_out(&state, jar, &login, ended, htmx);
2031 }
2032 None => match recall(&state, &jar, &client_ctx).await {
2033 Some((token, resolved, credential)) => (token, resolved, Some(credential)),
2034 None => {
2035 if ended.is_none() {
2038 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2039 ended = state
2040 .auth
2041 .remember_end(cookie.value())
2042 .await
2043 .ok()
2044 .flatten()
2045 .map(laterite_auth::SessionEnd::Revoked);
2046 }
2047 }
2048 return signed_out(&state, jar, &login, ended, htmx);
2049 }
2050 },
2051 };
2052 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
2053
2054 let mut request = request;
2059 let safe = session::is_safe_method(request.method());
2060 let mut deferred: Option<session::CsrfPending> = None;
2062 if !safe {
2063 let (parts, body) = request.into_parts();
2064 let (body, submitted) = if session::is_multipart(&parts.headers) {
2071 let token = session::header_token(&parts.headers)
2072 .or_else(|| session::query_token(parts.uri.query()));
2073 if token.is_none() {
2078 deferred = Some(session::CsrfPending::default());
2079 }
2080 (body, token)
2081 } else {
2082 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
2083 .await
2084 .unwrap_or_default();
2085 let token = session::submitted_token(&parts.headers, &bytes);
2086 (Body::from(bytes), token)
2087 };
2088 if deferred.is_none() && !session::token_matches(&handle.csrf_token(), submitted.as_deref())
2089 {
2090 tracing::warn!(
2091 user_id = resolved.identity.user.id,
2092 "admin CSRF check failed"
2093 );
2094 return error::csrf_rejected();
2095 }
2096 request = Request::from_parts(parts, body);
2097 }
2098
2099 let flash = if safe {
2102 handle.take_flash()
2103 } else {
2104 Vec::new()
2105 };
2106 let user = resolved.identity;
2107 let acting_user_id = user.user.id;
2109 let path = request.uri().path().to_string();
2110 let accept_language = request
2114 .headers()
2115 .get("accept-language")
2116 .and_then(|v| v.to_str().ok());
2117 let serveable = state.catalogs.locales();
2118 let chain = resolve_locale_chain(
2119 user.user.locale.as_deref(),
2120 accept_language,
2121 &state.default_locale,
2122 &serveable,
2123 );
2124 let i18n = Translator::with_chain(chain, state.catalogs.clone());
2125 let translator = i18n.clone();
2128 let (sidebar, active_nav) = resolve_nav_context(
2129 &state.nav,
2130 &state.settings,
2131 &state.admin_path,
2132 &user.permissions,
2133 &path,
2134 &i18n,
2135 icons::Icons::new(&state.icons, &state.sprite_url),
2136 );
2137 let brand = state.brand().await;
2138 let shell = Shell::new(
2139 &state.admin_path,
2140 brand,
2141 &state.nav,
2142 &user,
2143 state.timezone,
2144 sidebar,
2145 active_nav.as_deref(),
2146 handle.csrf_token(),
2147 flash,
2148 i18n,
2149 state.asset_urls.clone(),
2150 state.origin.to_string(),
2151 state.icons.clone(),
2152 state.sprite_url.clone(),
2153 );
2154 request.extensions_mut().insert(user);
2155 request.extensions_mut().insert(shell);
2156 request.extensions_mut().insert(handle.clone());
2157 if let Some(pending) = &deferred {
2159 request.extensions_mut().insert(pending.clone());
2160 }
2161 let mut response = next.run(request).await;
2162
2163 if let Some(pending) = &deferred {
2168 if !pending.was_satisfied() {
2169 tracing::error!(
2170 user_id = acting_user_id,
2171 "a multipart route returned without verifying its request token"
2172 );
2173 return error::csrf_rejected();
2174 }
2175 }
2176
2177 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
2181 response = error::localized_error(meta.kind, &translator);
2182 }
2183
2184 if let Some(blob) = handle.dirty_blob() {
2187 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
2188 tracing::error!(error = %e, "persisting admin session failed");
2189 }
2190 }
2191 if let Some(credential) = recalled {
2194 let jar = jar
2195 .add(session_cookie(
2196 token,
2197 &state.admin_path,
2198 state.secure_cookie,
2199 ))
2200 .add(remember_cookie(
2201 credential,
2202 &state.admin_path,
2203 state.secure_cookie,
2204 ));
2205 return (jar, response).into_response();
2206 }
2207 response
2208}
2209
2210async fn recall(
2214 state: &AdminState,
2215 jar: &CookieJar,
2216 ctx: &RequestContext,
2217) -> Option<(
2218 String,
2219 laterite_auth::ResolvedSession,
2220 laterite_auth::RememberCredential,
2221)> {
2222 let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
2223 let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
2224 let token = recalled.session.token.clone();
2225 let resolved = state.auth.resolve_session(&token).await.ok()?;
2226 Some((token, resolved, recalled.remember))
2227}
2228
2229const MAX_FORM_BYTES: usize = 1024 * 1024;
2232
2233async fn capture_client(
2241 State(state): State<AdminState>,
2242 mut request: Request,
2243 next: Next,
2244) -> Response {
2245 let peer = request
2246 .extensions()
2247 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
2248 .map(|info| info.0);
2249 let headers = request.headers();
2250 let ctx = RequestContext {
2251 ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
2252 user_agent: clientip::user_agent(headers),
2253 };
2254 request.extensions_mut().insert(ctx);
2255 next.run(request).await
2256}
2257
2258async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
2259 if !session::is_safe_method(request.method())
2260 && !session::origin_ok(request.headers(), &state.origin)
2261 {
2262 tracing::warn!(
2263 path = request.uri().path(),
2264 "admin CSRF origin check failed"
2265 );
2266 return error::csrf_rejected();
2267 }
2268 next.run(request).await
2269}
2270
2271#[derive(Deserialize)]
2272struct LoginQuery {
2273 ended: Option<String>,
2274}
2275
2276async fn login_form(
2277 State(state): State<AdminState>,
2278 headers: HeaderMap,
2279 Query(query): Query<LoginQuery>,
2280) -> Response {
2281 match state.auth.has_any_operator().await {
2283 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
2284 Ok(true) => {
2285 let i18n = pre_auth_translator(&state, &headers);
2286 let notice = query
2287 .ended
2288 .as_deref()
2289 .and_then(end_notice)
2290 .map(|text| i18n.t(&text));
2291 render(LoginTemplate {
2292 base: state.admin_path.to_string(),
2293 asset_urls: state.asset_urls.clone(),
2294 brand: state.brand().await,
2295 error: None,
2296 notice,
2297 i18n,
2298 })
2299 }
2300 Err(_) => render_error(),
2301 }
2302}
2303
2304#[derive(Deserialize)]
2305struct LoginForm {
2306 username: String,
2307 password: String,
2308 #[serde(default)]
2310 remember: Option<String>,
2311}
2312
2313async fn login_submit(
2314 State(state): State<AdminState>,
2315 jar: CookieJar,
2316 headers: HeaderMap,
2317 Extension(ctx): Extension<RequestContext>,
2318 Form(form): Form<LoginForm>,
2319) -> Response {
2320 match state
2321 .auth
2322 .authenticate(&form.username, &form.password, &ctx)
2323 .await
2324 {
2325 Ok(session) => {
2326 let wants_remember = form.remember.is_some();
2327 let user_id = session.user_id;
2328 let mut jar = jar.add(session_cookie(
2329 session.token,
2330 &state.admin_path,
2331 state.secure_cookie,
2332 ));
2333 if wants_remember {
2334 match state.auth.issue_remember(user_id).await {
2335 Ok(credential) => {
2336 jar = jar.add(remember_cookie(
2337 credential,
2338 &state.admin_path,
2339 state.secure_cookie,
2340 ));
2341 }
2342 Err(e) => {
2344 tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
2345 }
2346 }
2347 }
2348 (jar, Redirect::to(&state.admin_path)).into_response()
2349 }
2350 Err(_) => {
2351 let i18n = pre_auth_translator(&state, &headers);
2352 render(LoginTemplate {
2353 base: state.admin_path.to_string(),
2354 asset_urls: state.asset_urls.clone(),
2355 brand: state.brand().await,
2356 error: Some(i18n.t(&t!("Invalid username or password."))),
2357 notice: None,
2358 i18n,
2359 })
2360 }
2361 }
2362}
2363
2364fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
2373 Cookie::build((SESSION_COOKIE, token))
2374 .path(admin_path.to_string())
2375 .http_only(true)
2376 .secure(secure)
2377 .same_site(SameSite::Lax)
2378 .build()
2379}
2380
2381fn remember_cookie(
2384 credential: laterite_auth::RememberCredential,
2385 admin_path: &str,
2386 secure: bool,
2387) -> Cookie<'static> {
2388 let seconds = (credential.expires_at - chrono::Utc::now())
2389 .num_seconds()
2390 .max(0);
2391 Cookie::build((REMEMBER_COOKIE, credential.cookie))
2392 .path(admin_path.to_string())
2393 .http_only(true)
2394 .secure(secure)
2395 .same_site(SameSite::Lax)
2396 .max_age(cookie::time::Duration::seconds(seconds))
2397 .build()
2398}
2399
2400fn signed_out(
2407 state: &AdminState,
2408 jar: CookieJar,
2409 login: &str,
2410 ended: Option<laterite_auth::SessionEnd>,
2411 htmx: bool,
2412) -> Response {
2413 let to = match ended.map(laterite_auth::SessionEnd::code) {
2414 Some(code) => format!("{login}?ended={code}"),
2415 None => login.to_string(),
2416 };
2417 let jar = jar
2418 .remove(
2419 Cookie::build((SESSION_COOKIE, ""))
2420 .path(state.admin_path.to_string())
2421 .build(),
2422 )
2423 .remove(remember_removal(&state.admin_path));
2424 if htmx {
2425 let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
2426 if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
2427 response.headers_mut().insert("hx-redirect", value);
2428 }
2429 return response;
2430 }
2431 (jar, Redirect::to(&to)).into_response()
2432}
2433
2434fn end_notice(code: &str) -> Option<Text> {
2437 Some(match code {
2438 "password_changed" => {
2439 t!("Your password was changed, so you were signed out. Sign in with the new one.")
2440 }
2441 "deactivated" => t!("This account was deactivated."),
2442 "signed_out_elsewhere" => t!("You were signed out from another device."),
2443 "expired" => t!("Your session ended after a period of inactivity. Sign in again."),
2444 _ => return None,
2445 })
2446}
2447
2448fn remember_removal(admin_path: &str) -> Cookie<'static> {
2450 Cookie::build((REMEMBER_COOKIE, ""))
2451 .path(admin_path.to_string())
2452 .build()
2453}
2454
2455#[derive(Deserialize)]
2456struct SetupForm {
2457 username: String,
2458 first_name: String,
2459 last_name: String,
2460 email: String,
2461 password: String,
2462 timezone: String,
2463}
2464
2465async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
2468 match state.auth.has_any_operator().await {
2469 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2470 Ok(false) => render(setup_view(
2471 &state.admin_path,
2472 state.brand().await,
2473 state.timezone,
2474 None,
2475 pre_auth_translator(&state, &headers),
2476 state.asset_urls.clone(),
2477 )),
2478 Err(_) => render_error(),
2479 }
2480}
2481
2482async fn setup_submit(
2483 State(state): State<AdminState>,
2484 jar: CookieJar,
2485 headers: HeaderMap,
2486 Form(form): Form<SetupForm>,
2487) -> Response {
2488 match state.auth.has_any_operator().await {
2490 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2491 Ok(false) => {}
2492 Err(_) => return render_error(),
2493 }
2494
2495 let username = form.username.trim();
2496 let email = form.email.trim();
2497 let first_name = form.first_name.trim();
2498 let last_name = form.last_name.trim();
2499 let tz = form.timezone.trim();
2500 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
2501 {
2502 return render(setup_view(
2503 &state.admin_path,
2504 state.brand().await,
2505 state.timezone,
2506 Some(t!(
2507 "Username, first name, email, and password are all required."
2508 )),
2509 pre_auth_translator(&state, &headers),
2510 state.asset_urls.clone(),
2511 ));
2512 }
2513 if tz.parse::<Tz>().is_err() {
2515 return render(setup_view(
2516 &state.admin_path,
2517 state.brand().await,
2518 state.timezone,
2519 Some(t!("That is not a recognised timezone.")),
2520 pre_auth_translator(&state, &headers),
2521 state.asset_urls.clone(),
2522 ));
2523 }
2524
2525 let new = NewOperator {
2526 username,
2527 email,
2528 first_name,
2529 last_name: (!last_name.is_empty()).then_some(last_name),
2530 password: &form.password,
2531 timezone: Some(tz),
2532 };
2533 if state.auth.create_superuser(new).await.is_err() {
2534 return render(setup_view(
2535 &state.admin_path,
2536 state.brand().await,
2537 state.timezone,
2538 Some(t!(
2539 "Could not create the account. The username or email may already be taken."
2540 )),
2541 pre_auth_translator(&state, &headers),
2542 state.asset_urls.clone(),
2543 ));
2544 }
2545
2546 match state
2548 .auth
2549 .authenticate(username, &form.password, &RequestContext::default())
2550 .await
2551 {
2552 Ok(session) => {
2553 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
2554 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
2555 }
2556 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
2557 }
2558}
2559
2560fn setup_view(
2563 admin_path: &str,
2564 brand: String,
2565 default_tz: Tz,
2566 error: Option<Text>,
2567 i18n: Translator,
2568 asset_urls: Arc<AssetUrls>,
2569) -> SetupTemplate {
2570 let default_name = default_tz.name();
2571 let zones = TZ_VARIANTS
2572 .iter()
2573 .filter(|tz| zone_offered(tz.name()))
2574 .map(|tz| TzOption {
2575 name: tz.name().to_string(),
2576 selected: tz.name() == default_name,
2577 })
2578 .collect();
2579 SetupTemplate {
2580 base: admin_path.to_string(),
2581 asset_urls,
2582 brand,
2583 zones,
2584 error: error.map(|e| i18n.t(&e)),
2585 i18n,
2586 }
2587}
2588
2589async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
2590 if let Some(cookie) = jar.get(SESSION_COOKIE) {
2591 let _ = state.auth.logout(cookie.value()).await;
2592 }
2593 if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
2596 let _ = state.auth.revoke_remember(cookie.value()).await;
2597 }
2598 let removal = Cookie::build((SESSION_COOKIE, ""))
2599 .path(state.admin_path.to_string())
2600 .build();
2601 (
2602 jar.remove(removal)
2603 .remove(remember_removal(&state.admin_path)),
2604 Redirect::to(&format!("{}/login", state.admin_path)),
2605 )
2606 .into_response()
2607}
2608
2609async fn dashboard(
2610 Extension(shell): Extension<Shell>,
2611 Extension(user): Extension<AuthenticatedUser>,
2612) -> Response {
2613 render(DashboardTemplate {
2614 username: user.user.username,
2615 shell,
2616 })
2617}
2618
2619fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
2622 match &item.permission {
2623 None => true,
2624 Some(p) => perms.allows(p),
2625 }
2626}
2627
2628fn visible_settings(
2632 items: &[settings::SettingsItem],
2633 perms: &PermissionSet,
2634) -> Vec<settings::SettingsItem> {
2635 items
2636 .iter()
2637 .filter(|item| operator_can_see(item, perms))
2638 .cloned()
2639 .collect()
2640}
2641
2642async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
2643 settings::index(shell)
2644}
2645
2646async fn settings_edit(
2647 state: AdminState,
2648 shell: Shell,
2649 user: AuthenticatedUser,
2650 code: String,
2651) -> Response {
2652 let items = visible_settings(&state.settings, &user.permissions);
2655 match items
2656 .iter()
2657 .find(|item| item.code == code && item.link.is_none())
2658 {
2659 Some(item) => settings::edit_form(&state, item, shell).await,
2660 None => not_found(),
2661 }
2662}
2663
2664async fn settings_update(
2665 state: AdminState,
2666 shell: Shell,
2667 user: AuthenticatedUser,
2668 session: session::SessionHandle,
2669 code: String,
2670 data: HashMap<String, String>,
2671) -> Response {
2672 let items = visible_settings(&state.settings, &user.permissions);
2673 match items
2674 .iter()
2675 .find(|item| item.code == code && item.link.is_none())
2676 {
2677 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
2678 None => not_found(),
2679 }
2680}
2681
2682async fn preferences_form(
2684 State(state): State<AdminState>,
2685 Extension(shell): Extension<Shell>,
2686 Extension(user): Extension<AuthenticatedUser>,
2687 jar: CookieJar,
2688) -> Response {
2689 let account = account_security(&state, &user, &shell, &jar).await;
2690 render(preferences_view(
2691 &shell,
2692 &user,
2693 state.timezone,
2694 &state.default_locale,
2695 &offered_locales(&state.catalogs),
2696 account,
2697 None,
2698 ))
2699}
2700
2701#[derive(Deserialize)]
2702struct RevokeSessionForm {
2703 id: String,
2704}
2705
2706async fn session_revoke(
2708 State(state): State<AdminState>,
2709 Extension(user): Extension<AuthenticatedUser>,
2710 Extension(session): Extension<session::SessionHandle>,
2711 Form(form): Form<RevokeSessionForm>,
2712) -> Response {
2713 match state.auth.revoke_session(user.user.id, &form.id).await {
2714 Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
2715 Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
2716 Err(e) => {
2717 tracing::error!(error = %e, "revoking a session failed");
2718 session.push_flash(
2719 FlashLevel::Error,
2720 t!("That session could not be signed out."),
2721 );
2722 }
2723 }
2724 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2725}
2726
2727async fn session_revoke_others(
2729 State(state): State<AdminState>,
2730 Extension(user): Extension<AuthenticatedUser>,
2731 Extension(session): Extension<session::SessionHandle>,
2732 jar: CookieJar,
2733) -> Response {
2734 let token = jar
2735 .get(SESSION_COOKIE)
2736 .map(|c| c.value().to_string())
2737 .unwrap_or_default();
2738 match state.auth.sign_out_everywhere(user.user.id, &token).await {
2739 Ok(_) => session.push_flash(
2740 FlashLevel::Success,
2741 t!("Every other session was signed out."),
2742 ),
2743 Err(e) => {
2744 tracing::error!(error = %e, "signing out other sessions failed");
2745 session.push_flash(
2746 FlashLevel::Error,
2747 t!("Those sessions could not be signed out."),
2748 );
2749 }
2750 }
2751 (
2754 jar.remove(remember_removal(&state.admin_path)),
2755 Redirect::to(&format!("{}/preferences", state.admin_path)),
2756 )
2757 .into_response()
2758}
2759
2760#[derive(Deserialize)]
2761struct PasswordForm {
2762 current_password: String,
2763 new_password: String,
2764 confirm_password: String,
2765}
2766
2767async fn password_update(
2771 State(state): State<AdminState>,
2772 Extension(shell): Extension<Shell>,
2773 Extension(user): Extension<AuthenticatedUser>,
2774 Extension(session): Extension<session::SessionHandle>,
2775 Extension(ctx): Extension<RequestContext>,
2776 jar: CookieJar,
2777 Form(form): Form<PasswordForm>,
2778) -> Response {
2779 let refusal = if form.new_password != form.confirm_password {
2780 Some(t!("The new passwords do not match."))
2781 } else if form.new_password.chars().count() < laterite_auth::MIN_PASSWORD_LENGTH {
2782 Some(t!("A password needs at least 8 characters."))
2783 } else {
2784 let token = jar
2785 .get(SESSION_COOKIE)
2786 .map(|c| c.value().to_string())
2787 .unwrap_or_default();
2788 let changed = state
2789 .auth
2790 .change_own_password(
2791 user.user.id,
2792 &form.current_password,
2793 &form.new_password,
2794 &token,
2795 &ctx,
2796 )
2797 .await;
2798 match changed {
2799 Ok(()) => None,
2800 Err(laterite_auth::AuthError::InvalidCredentials) => {
2801 Some(t!("Your current password is not correct."))
2802 }
2803 Err(laterite_auth::AuthError::TooManyAttempts) => {
2804 Some(t!("Too many attempts. Try again later."))
2805 }
2806 Err(e) => {
2807 tracing::error!(error = %e, "changing a password failed");
2808 Some(t!("The password could not be changed."))
2809 }
2810 }
2811 };
2812 if let Some(message) = refusal {
2813 let mut page = preferences_view(
2814 &shell,
2815 &user,
2816 state.timezone,
2817 &state.default_locale,
2818 &offered_locales(&state.catalogs),
2819 account_security(&state, &user, &shell, &jar).await,
2820 None,
2821 );
2822 page.password_error = Some(shell.tt(&message));
2823 return render(page);
2824 }
2825 session.push_flash(
2826 FlashLevel::Success,
2827 t!("Password changed. Every other device was signed out."),
2828 );
2829 let mut jar = jar;
2830 if jar.get(REMEMBER_COOKIE).is_some() {
2831 jar = match state.auth.issue_remember(user.user.id).await {
2832 Ok(credential) => jar.add(remember_cookie(
2833 credential,
2834 &state.admin_path,
2835 state.secure_cookie,
2836 )),
2837 Err(e) => {
2838 tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
2839 jar.remove(remember_removal(&state.admin_path))
2840 }
2841 };
2842 }
2843 (
2844 jar,
2845 Redirect::to(&format!("{}/preferences", state.admin_path)),
2846 )
2847 .into_response()
2848}
2849
2850#[derive(Deserialize)]
2851struct PreferencesForm {
2852 timezone: String,
2853 #[serde(default)]
2855 locale: String,
2856}
2857
2858async fn preferences_update(
2859 State(state): State<AdminState>,
2860 Extension(shell): Extension<Shell>,
2861 Extension(user): Extension<AuthenticatedUser>,
2862 Extension(session): Extension<session::SessionHandle>,
2863 jar: CookieJar,
2864 Form(form): Form<PreferencesForm>,
2865) -> Response {
2866 let offered = offered_locales(&state.catalogs);
2867 let tz = form.timezone.trim();
2869 let tz_stored = if tz.is_empty() {
2870 None
2871 } else if tz.parse::<Tz>().is_ok() {
2872 Some(tz)
2873 } else {
2874 return render(preferences_view(
2875 &shell,
2876 &user,
2877 state.timezone,
2878 &state.default_locale,
2879 &offered,
2880 account_security(&state, &user, &shell, &jar).await,
2881 Some(t!("That is not a recognised timezone.")),
2882 ));
2883 };
2884 let loc = form.locale.trim();
2885 let loc_stored = if loc.is_empty() {
2886 None
2887 } else if offered.iter().any(|l| l == loc) {
2888 Some(loc)
2889 } else {
2890 return render(preferences_view(
2891 &shell,
2892 &user,
2893 state.timezone,
2894 &state.default_locale,
2895 &offered,
2896 account_security(&state, &user, &shell, &jar).await,
2897 Some(t!("That is not a supported language.")),
2898 ));
2899 };
2900 if state
2901 .auth
2902 .set_user_timezone(user.user.id, tz_stored)
2903 .await
2904 .is_err()
2905 {
2906 return render_error();
2907 }
2908 match state.auth.set_user_locale(user.user.id, loc_stored).await {
2909 Ok(()) => {
2910 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
2911 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2912 }
2913 Err(_) => render_error(),
2914 }
2915}
2916
2917fn preferences_view(
2921 shell: &Shell,
2922 user: &AuthenticatedUser,
2923 default_tz: Tz,
2924 default_locale: &str,
2925 offered: &[String],
2926 account: AccountSecurity,
2927 error: Option<Text>,
2928) -> PreferencesTemplate {
2929 let current = user.user.timezone.as_deref();
2930 let zones = TZ_VARIANTS
2931 .iter()
2932 .filter(|tz| zone_offered(tz.name()))
2933 .map(|tz| TzOption {
2934 name: tz.name().to_string(),
2935 selected: current == Some(tz.name()),
2936 })
2937 .collect();
2938 let current_locale = user.user.locale.as_deref();
2939 let locales = offered
2940 .iter()
2941 .map(|code| LocaleOption {
2942 name: locale_name(code),
2943 selected: current_locale == Some(code.as_str()),
2944 code: code.clone(),
2945 })
2946 .collect();
2947 PreferencesTemplate {
2948 shell: shell.clone(),
2949 zones,
2950 effective_tz: shell.tz.name().to_string(),
2951 default_tz: default_tz.name().to_string(),
2952 inherits: current.is_none(),
2953 locales,
2954 default_locale: locale_name(default_locale),
2955 inherits_locale: current_locale.is_none(),
2956 sessions: account.sessions,
2957 password_changed: account.password_changed,
2958 password_error: None,
2959 error: error.map(|e| shell.tt(&e)),
2960 }
2961}
2962
2963struct AccountSecurity {
2966 sessions: Vec<SessionRow>,
2967 password_changed: Option<String>,
2968}
2969
2970async fn account_security(
2971 state: &AdminState,
2972 user: &AuthenticatedUser,
2973 shell: &Shell,
2974 jar: &CookieJar,
2975) -> AccountSecurity {
2976 let locale = list::date_locale(shell.locale());
2977 let password_changed = match state.auth.password_changed_at(user.user.id).await {
2978 Ok(at) => {
2979 at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
2980 }
2981 Err(e) => {
2982 tracing::error!(error = %e, "reading the password change time failed");
2983 None
2984 }
2985 };
2986 AccountSecurity {
2987 sessions: session_rows(state, user, shell, jar).await,
2988 password_changed,
2989 }
2990}
2991
2992async fn session_rows(
2995 state: &AdminState,
2996 user: &AuthenticatedUser,
2997 shell: &Shell,
2998 jar: &CookieJar,
2999) -> Vec<SessionRow> {
3000 let token = jar
3001 .get(SESSION_COOKIE)
3002 .map(|c| c.value().to_string())
3003 .unwrap_or_default();
3004 let locale = list::date_locale(shell.locale());
3005 let stamp = |dt: chrono::DateTime<chrono::Utc>| {
3006 list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
3007 };
3008 match state.auth.list_sessions(user.user.id, &token).await {
3009 Ok(sessions) => sessions
3010 .into_iter()
3011 .map(|s| SessionRow {
3012 id: s.id,
3013 started: stamp(s.created_at),
3014 last_seen: stamp(s.last_seen_at),
3015 expires: stamp(s.expires_at),
3016 device: clientip::describe(s.user_agent.as_deref())
3017 .or(s.user_agent)
3018 .unwrap_or_else(|| "-".to_string()),
3019 ip_address: s.ip_address.unwrap_or_default(),
3020 current: s.current,
3021 })
3022 .collect(),
3023 Err(e) => {
3024 tracing::error!(error = %e, "listing sessions failed");
3025 Vec::new()
3026 }
3027 }
3028}
3029
3030fn builtin_resources() -> Vec<Resource> {
3032 vec![
3033 Resource {
3034 base_path: "/users".to_string(),
3035 nav_label: "Backend Users".into(),
3036 list: backend_users_list_config(),
3037 form: None,
3038 permission: Some("backend.manage_users".to_string()),
3039 },
3040 Resource {
3041 base_path: "/roles".to_string(),
3042 nav_label: "Roles".into(),
3043 list: roles_list_config(),
3044 form: None,
3047 permission: Some("backend.manage_roles".to_string()),
3048 },
3049 resource!("descriptors/audit-log.yaml"),
3050 ]
3051}
3052
3053fn builtin_settings() -> Vec<settings::SettingsItem> {
3057 vec![
3058 settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
3059 .description("Manage backend administrator accounts.")
3060 .category("Users")
3061 .order(10)
3062 .icon("users")
3063 .permission("backend.manage_users")
3064 .link("/users"),
3065 settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
3066 .description("Manage roles and their permissions.")
3067 .category("Users")
3068 .order(20)
3069 .icon("shield")
3070 .permission("backend.manage_roles")
3071 .link("/roles"),
3072 settings::brand::settings_item(),
3073 settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
3074 .description("Enable or disable installed plugins.")
3075 .category("System")
3076 .order(10)
3077 .icon("plug")
3078 .permission(plugins::MANAGE_PERMISSION)
3079 .link("/plugins"),
3080 settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
3081 .description("Review the record of administrative changes.")
3082 .category("System")
3083 .order(20)
3084 .icon("history")
3085 .permission("backend.view_audit_log")
3086 .link("/audit-log"),
3087 ]
3088}
3089
3090fn backend_users_list_config() -> list::ListConfig {
3091 list::ListConfig {
3092 entity: "backend_users".to_string(),
3093 title: "Backend Users".into(),
3094 columns: vec![
3095 list::ListColumn::new("username", "Username"),
3096 list::ListColumn::new("email", "Email"),
3097 list::ListColumn::new("first_name", "First name"),
3098 list::ListColumn::new("last_name", "Last name"),
3099 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
3100 list::ListColumn::new("is_active", "Active").yes_no(),
3101 list::ListColumn::new("created_at", "Created").datetime(),
3102 ],
3103 order_by: "created_at".to_string(),
3104 edit_base: Some("/users".to_string()),
3107 filters: vec![
3108 list::ListFilter::boolean("is_active", "Active"),
3109 list::ListFilter::boolean("is_superuser", "Superuser"),
3110 ],
3111 ..Default::default()
3115 }
3116}
3117
3118fn roles_list_config() -> list::ListConfig {
3119 list::ListConfig {
3120 entity: "backend_roles".to_string(),
3121 title: "Roles".into(),
3122 columns: vec![
3123 list::ListColumn::new("code", "Code"),
3124 list::ListColumn::new("name", "Name"),
3125 list::ListColumn::new("created_at", "Created").datetime(),
3126 ],
3127 order_by: "created_at".to_string(),
3128 edit_base: Some("/roles".to_string()),
3129 creatable: true,
3130 deletable: true,
3131 ..Default::default()
3132 }
3133}
3134
3135#[derive(Template)]
3136#[template(path = "login.html")]
3137struct LoginTemplate {
3138 base: String,
3140 asset_urls: Arc<AssetUrls>,
3142 brand: String,
3143 error: Option<String>,
3144 notice: Option<String>,
3146 i18n: Translator,
3149}
3150
3151fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
3154 match urls.get(key) {
3155 Some(path) => format!("{base}/assets/{path}"),
3156 None => {
3157 debug_assert!(false, "asset key `{key}` is not registered");
3158 format!("{base}/assets/{key}")
3159 }
3160 }
3161}
3162
3163impl LoginTemplate {
3164 fn asset(&self, key: &str) -> String {
3165 preauth_asset(&self.asset_urls, &self.base, key)
3166 }
3167 fn t(&self, source: &str) -> String {
3168 self.i18n.t(&Text::dynamic(source))
3169 }
3170 fn locale(&self) -> &str {
3171 self.i18n.locale()
3172 }
3173}
3174
3175#[derive(Template)]
3176#[template(path = "dashboard.html")]
3177struct DashboardTemplate {
3178 shell: Shell,
3179 username: String,
3180}
3181
3182#[derive(Template)]
3183#[template(path = "setup.html")]
3184struct SetupTemplate {
3185 base: String,
3187 asset_urls: Arc<AssetUrls>,
3189 brand: String,
3190 zones: Vec<TzOption>,
3191 error: Option<String>,
3192 i18n: Translator,
3194}
3195
3196impl SetupTemplate {
3197 fn asset(&self, key: &str) -> String {
3198 preauth_asset(&self.asset_urls, &self.base, key)
3199 }
3200 fn t(&self, source: &str) -> String {
3201 self.i18n.t(&Text::dynamic(source))
3202 }
3203 fn locale(&self) -> &str {
3204 self.i18n.locale()
3205 }
3206}
3207
3208#[derive(Template)]
3209#[template(path = "preferences.html")]
3210struct PreferencesTemplate {
3211 shell: Shell,
3212 zones: Vec<TzOption>,
3213 effective_tz: String,
3215 default_tz: String,
3217 inherits: bool,
3219 locales: Vec<LocaleOption>,
3220 default_locale: String,
3222 inherits_locale: bool,
3224 sessions: Vec<SessionRow>,
3226 password_changed: Option<String>,
3228 password_error: Option<String>,
3230 error: Option<String>,
3231}
3232
3233struct SessionRow {
3235 id: String,
3236 started: String,
3237 last_seen: String,
3238 expires: String,
3239 device: String,
3242 ip_address: String,
3244 current: bool,
3245}
3246
3247struct TzOption {
3248 name: String,
3249 selected: bool,
3250}
3251
3252struct LocaleOption {
3253 code: String,
3255 name: String,
3257 selected: bool,
3258}
3259
3260#[derive(Clone)]
3261struct NavView {
3262 label: String,
3263 path: String,
3264 active: bool,
3265 icon: String,
3268}
3269
3270pub(crate) fn render<T: Template>(template: T) -> Response {
3272 match template.render() {
3273 Ok(html) => Html(html).into_response(),
3274 Err(e) => AdminError::from(e).into_response(),
3276 }
3277}
3278
3279pub(crate) fn render_error() -> Response {
3280 error::masked_500()
3281}
3282
3283pub(crate) fn not_found() -> Response {
3284 AdminError::NotFound.into_response()
3285}
3286
3287pub(crate) fn forbidden() -> Response {
3288 AdminError::Forbidden.into_response()
3289}
3290
3291#[cfg(test)]
3292mod tests {
3293 use super::*;
3294
3295 fn write_form() -> form::FormConfig {
3296 form::FormConfig {
3297 entity: "widgets".to_string(),
3298 title: "Widgets".into(),
3299 base_path: "/widgets".to_string(),
3300 id_field: "id".to_string(),
3301 fields: vec![],
3302 persist: None,
3303 timestamps: false,
3304 }
3305 }
3306
3307 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
3308 Resource {
3309 base_path: "/widgets".to_string(),
3310 nav_label: "Widgets".into(),
3311 list: backend_users_list_config(),
3312 form,
3313 permission: permission.map(str::to_string),
3314 }
3315 }
3316
3317 #[test]
3318 #[should_panic(expected = "must declare a permission")]
3319 fn write_resource_without_permission_aborts_boot() {
3320 let resource = resource_with(Some(write_form()), None);
3323 let _ = mount_resource(
3324 &resource,
3325 &field::builtin_registry(),
3326 &list::builtin_column_registry(),
3327 &std::collections::HashSet::new(),
3328 &persist::PersisterRegistry::new(),
3329 &[],
3330 );
3331 }
3332
3333 #[test]
3336 fn the_audit_log_is_read_from_its_descriptor_file() {
3337 let audit = builtin_resources()
3338 .into_iter()
3339 .find(|r| r.base_path == "/audit-log")
3340 .expect("the audit log is registered");
3341 assert_eq!(audit.list.entity, "backend_audit_log");
3342 assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
3343 assert_eq!(
3344 audit
3345 .list
3346 .columns
3347 .iter()
3348 .map(|c| c.field.as_str())
3349 .collect::<Vec<_>>(),
3350 [
3351 "created_at",
3352 "actor_username",
3353 "action",
3354 "target_type",
3355 "target_id"
3356 ],
3357 "in the order the file writes them"
3358 );
3359 assert_eq!(audit.list.columns[0].column_type, "datetime");
3360 assert_eq!(audit.list.per_page, 50);
3361 assert_eq!(audit.list.per_page_options, [25, 50, 100]);
3362 assert!(audit.list.exportable, "evidence may leave the panel");
3363 assert!(audit.form.is_none(), "append-only: no form");
3364 assert!(!audit.list.deletable, "and nothing removes from it");
3365 }
3366
3367 #[test]
3368 #[should_panic(expected = "uses unregistered type `nope`")]
3369 fn unregistered_column_type_aborts_boot() {
3370 let mut resource = resource_with(None, None);
3371 let mut score = list::ListColumn::new("score", "Score");
3372 score.column_type = "nope".into();
3373 resource.list.columns.push(score);
3374 let _ = mount_resource(
3375 &resource,
3376 &field::builtin_registry(),
3377 &list::builtin_column_registry(),
3378 &std::collections::HashSet::new(),
3379 &persist::PersisterRegistry::new(),
3380 &[],
3381 );
3382 }
3383
3384 #[test]
3385 fn read_only_resource_may_omit_permission() {
3386 let resource = resource_with(None, None);
3388 let _ = mount_resource(
3389 &resource,
3390 &field::builtin_registry(),
3391 &list::builtin_column_registry(),
3392 &std::collections::HashSet::new(),
3393 &persist::PersisterRegistry::new(),
3394 &[],
3395 );
3396 }
3397
3398 #[test]
3399 fn shell_localizes_through_the_translator_and_falls_back() {
3400 let shell = Shell::test();
3402 assert_eq!(shell.t("Save"), "Save");
3403 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
3404 }
3405
3406 #[test]
3407 fn shell_tf_interpolates_integer_arguments() {
3408 let shell = Shell::test();
3409 assert_eq!(
3410 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
3411 "Page 2 of 5"
3412 );
3413 }
3414
3415 #[test]
3416 fn display_tz_prefers_a_valid_operator_preference() {
3417 assert_eq!(
3418 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
3419 Tz::Asia__Kolkata
3420 );
3421 }
3422
3423 #[test]
3424 fn display_tz_falls_back_when_unset_or_invalid() {
3425 let default = Tz::Europe__London;
3426 assert_eq!(resolve_display_tz(None, default), default);
3428 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
3430 assert_eq!(resolve_display_tz(Some(""), default), default);
3431 }
3432
3433 #[test]
3434 fn accept_language_orders_by_weight_and_drops_wildcard() {
3435 assert_eq!(
3436 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
3437 vec!["en-US", "en", "kn"]
3438 );
3439 assert_eq!(
3441 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
3442 vec!["kn", "en"]
3443 );
3444 }
3445
3446 #[test]
3447 fn locale_chain_prefers_operator_then_header_then_default() {
3448 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
3450 assert_eq!(
3453 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
3454 vec!["kn", "ta", "hi", "en"]
3455 );
3456 assert_eq!(
3458 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
3459 vec!["kn", "en", "ta"]
3460 );
3461 assert_eq!(
3463 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
3464 vec!["kn", "en"]
3465 );
3466 }
3467
3468 #[test]
3469 fn locale_chain_skips_unserveable_and_defaults_to_en() {
3470 let loaded = ["hi".to_string()];
3472 assert_eq!(
3475 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
3476 vec!["en"]
3477 );
3478 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
3480 assert_eq!(
3482 resolve_locale_chain(Some("hi"), None, "en", &loaded),
3483 vec!["hi", "en"]
3484 );
3485 assert_eq!(
3487 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
3488 vec![laterite_core::PSEUDO_LOCALE, "en"]
3489 );
3490 }
3491
3492 #[test]
3493 fn page_assets_dedupe_preserve_order_and_classify() {
3494 let mut reg = AssetRegistry::new();
3495 reg.insert(
3496 "a.js",
3497 AdminAsset {
3498 mime: "text/javascript; charset=utf-8",
3499 bytes: std::borrow::Cow::Borrowed(b"//"),
3500 },
3501 );
3502 reg.insert(
3503 "b.css",
3504 AdminAsset {
3505 mime: "text/css; charset=utf-8",
3506 bytes: std::borrow::Cow::Borrowed(b"body{}"),
3507 },
3508 );
3509 let urls = asset_urls(®);
3512 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
3513 assert_eq!(assets.len(), 2);
3514 assert_eq!(
3515 assets[0].url,
3516 format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
3517 );
3518 assert!(!assets[0].css);
3519 assert_eq!(
3520 assets[1].url,
3521 format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
3522 );
3523 assert!(assets[1].css);
3524 assert_ne!(assets[0].url, assets[1].url);
3527 }
3528
3529 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
3533 laterite_core::testing::connect_test(&[]).await
3534 }
3535
3536 #[tokio::test]
3537 async fn builtin_migrations_create_the_admin_tables() {
3538 let (db, _guard) = empty_db().await;
3539 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
3540 .await
3541 .unwrap();
3542 for table in ["backend_users", "settings"] {
3546 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
3547 .fetch_optional(&db.pool)
3548 .await;
3549 assert!(
3550 probe.is_ok(),
3551 "{table} should exist after builtin_migrations"
3552 );
3553 }
3554 }
3555
3556 #[tokio::test]
3557 async fn brand_setting_overrides_config_and_blank_falls_back() {
3558 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
3559 let state = AdminState {
3560 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3561 db: db.clone(),
3562 nav: Arc::new(Vec::new()),
3563 settings: Arc::new(Vec::new()),
3564 permissions: Arc::new(builtin_permissions()),
3565 trusted_proxies: Arc::new(Vec::new()),
3566 icons: Arc::new(laterite_core::icons::IconSet::new()),
3567 sprite_url: Arc::from(""),
3568 admin_path: Arc::from("/admin"),
3569 secure_cookie: false,
3570 origin: Arc::from(""),
3571 timezone: Tz::UTC,
3572 default_locale: Arc::from("en"),
3573 catalogs: Arc::new(CatalogStore::default()),
3574 app_name: "Configured Name".to_string(),
3575 brand_cache: Arc::new(RwLock::new(None)),
3576 field_types: Arc::new(field::builtin_registry()),
3577 column_types: Arc::new(list::builtin_column_registry()),
3578 plugin_defined: Arc::new(laterite_core::Registry::new()),
3579 assets: Arc::new(builtin_assets()),
3580 asset_urls: Arc::new(asset_urls(&builtin_assets())),
3581 pickers: Arc::new(picker::PickerRegistry::new()),
3582 overrides: Arc::new(field::NoOverrides),
3583 };
3584
3585 assert_eq!(state.brand().await, "Configured Name");
3587
3588 settings::store::save(
3591 &db,
3592 &settings::BrandSetting {
3593 app_name: "Acme Corp".to_string(),
3594 },
3595 )
3596 .await
3597 .unwrap();
3598 state.invalidate_brand();
3599 assert_eq!(state.brand().await, "Acme Corp");
3600
3601 settings::store::save(
3603 &db,
3604 &settings::BrandSetting {
3605 app_name: " ".to_string(),
3606 },
3607 )
3608 .await
3609 .unwrap();
3610 state.invalidate_brand();
3611 assert_eq!(state.brand().await, "Configured Name");
3612 }
3613
3614 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
3615 settings::SettingsItem {
3616 code: code.to_string(),
3617 label: code.into(),
3618 description: String::new().into(),
3619 category: "General".into(),
3620 hint: None,
3621 order: 1,
3622 icon: None,
3623 permission: permission.map(str::to_string),
3624 link: None,
3625 fields: Vec::new(),
3626 route: None,
3627 }
3628 }
3629
3630 #[test]
3631 fn settings_visibility_respects_permissions() {
3632 let items = vec![
3633 settings_item("public", None),
3634 settings_item("gated", Some("backend.manage_users")),
3635 ];
3636
3637 let none = PermissionSet::new(false, Vec::<String>::new());
3639 let codes: Vec<String> = visible_settings(&items, &none)
3640 .into_iter()
3641 .map(|i| i.code)
3642 .collect();
3643 assert_eq!(codes, ["public"]);
3644
3645 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
3647 assert_eq!(visible_settings(&items, &granted).len(), 2);
3648
3649 let superuser = PermissionSet::new(true, Vec::<String>::new());
3651 assert_eq!(visible_settings(&items, &superuser).len(), 2);
3652 }
3653
3654 #[test]
3655 fn context_sidebar_follows_settings_links() {
3656 let items = builtin_settings();
3657 let superuser = PermissionSet::new(true, Vec::<String>::new());
3658 let tr = Translator::new("en");
3659 let sidebar = |path: &str| {
3660 resolve_nav_context(
3661 &[],
3662 &items,
3663 "/admin",
3664 &superuser,
3665 path,
3666 &tr,
3667 icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
3668 )
3669 .0
3670 };
3671 let active_path = |path: &str| -> Option<String> {
3672 sidebar(path)
3673 .into_iter()
3674 .flat_map(|g| g.items)
3675 .find(|i| i.active)
3676 .map(|i| i.path)
3677 };
3678
3679 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
3681 assert_eq!(
3682 active_path("/admin/roles/42/edit").as_deref(),
3683 Some("/admin/roles")
3684 );
3685 assert!(!sidebar("/admin/settings").is_empty());
3687 assert_eq!(active_path("/admin/settings"), None);
3688 assert_eq!(
3693 active_path("/admin/settings/branding").as_deref(),
3694 Some("/admin/settings/branding")
3695 );
3696 assert_eq!(active_path("/admin/settings/laterite.brand"), None);
3699 assert_eq!(active_path("/admin/settings/laterite/brand"), None);
3700 assert!(sidebar("/admin").is_empty());
3702 }
3703
3704 #[test]
3705 fn active_nav_lights_the_right_tab() {
3706 let nav = vec![
3707 NavLink {
3708 label: "Dashboard".into(),
3709 path: "/admin".to_string(),
3710 icon: Some("layout-dashboard"),
3711 },
3712 NavLink {
3713 label: "Pages".into(),
3714 path: "/admin/pages".to_string(),
3715 icon: None,
3716 },
3717 NavLink {
3718 label: "Settings".into(),
3719 path: "/admin/settings".to_string(),
3720 icon: Some("settings"),
3721 },
3722 ];
3723
3724 assert_eq!(
3726 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
3727 Some("/admin")
3728 );
3729 assert_eq!(
3731 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
3732 Some("/admin/pages")
3733 );
3734 assert_eq!(
3736 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
3737 None
3738 );
3739 assert_eq!(
3742 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
3743 None
3744 );
3745 assert_eq!(
3748 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
3749 Some("/admin/settings")
3750 );
3751 assert_eq!(
3752 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
3753 Some("/admin/settings")
3754 );
3755 }
3756}
3757
3758#[cfg(test)]
3759mod session_cookie_tests {
3760 use super::*;
3761
3762 #[test]
3766 fn the_session_cookie_never_outlives_the_browser() {
3767 let cookie = session_cookie("tok".into(), "/admin", false);
3768 assert_eq!(cookie.max_age(), None);
3769 assert!(cookie.http_only().unwrap_or(false));
3770 assert_eq!(cookie.path(), Some("/admin"));
3771 }
3772
3773 #[test]
3776 fn the_remember_cookie_carries_its_own_lifetime() {
3777 let credential = laterite_auth::RememberCredential {
3778 cookie: "sel:ver".into(),
3779 expires_at: chrono::Utc::now() + chrono::Duration::days(14),
3780 };
3781 let cookie = remember_cookie(credential, "/admin", true);
3782 let age = cookie.max_age().expect("a remember cookie must persist");
3783 assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
3785 assert!(cookie.http_only().unwrap_or(false));
3786 assert!(cookie.secure().unwrap_or(false));
3787 }
3788
3789 #[test]
3791 fn the_removal_matches_the_cookie_it_clears() {
3792 let removal = remember_removal("/admin");
3793 assert_eq!(removal.name(), REMEMBER_COOKIE);
3794 assert_eq!(removal.path(), Some("/admin"));
3795 }
3796}