1mod audit;
18pub mod bootstrap;
19mod bulk;
20mod error;
21mod export;
22pub mod field;
23pub mod form;
24pub mod html;
25mod icons;
26pub mod list;
27pub mod persist;
28pub mod picker;
29pub mod plugins;
30mod roles;
31mod routemap;
32pub mod routes;
33mod session;
34pub mod settings;
35mod sql;
36mod users;
37
38pub use axum;
46
47pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
48pub use error::AdminError;
49pub use session::{FlashLevel, SessionHandle};
50
51use std::collections::HashMap;
52use std::sync::{Arc, RwLock};
53
54use askama::Template;
55use axum::body::Body;
56use axum::extract::{Path, Query, Request, State};
57use axum::http::{header, HeaderMap};
58use axum::middleware::{self, Next};
59use axum::response::{Html, IntoResponse, Redirect, Response};
60use axum::routing::{any, get, post};
61use axum::{Extension, Form, Router};
62use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
63use chrono_tz::{Tz, TZ_VARIANTS};
64use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
65use laterite_core::{t, CatalogStore, Db, Text, Translator};
66use serde::{Deserialize, Serialize};
67
68pub trait AdminRegistry {
74 fn add_resource(&mut self, resource: Resource);
76 fn add_permission(&mut self, permission: Permission);
78 fn add_settings(&mut self, item: settings::SettingsItem);
80 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
82 fn add_persister(&mut self, persister: persist::PersisterReg);
84 fn add_screen(&mut self, screen: routes::ScreenReg);
87 fn add_public_route(&mut self, route: routes::PublicRouteReg);
90}
91
92impl AdminRegistry for laterite_core::Registry {
93 fn add_resource(&mut self, resource: Resource) {
94 self.add(resource);
95 }
96 fn add_permission(&mut self, permission: Permission) {
97 self.add(permission);
98 }
99 fn add_settings(&mut self, item: settings::SettingsItem) {
100 self.add(item);
101 }
102 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
103 self.add(source);
104 }
105 fn add_screen(&mut self, screen: routes::ScreenReg) {
106 self.add(screen);
107 }
108 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
109 self.add(route);
110 }
111 fn add_persister(&mut self, persister: persist::PersisterReg) {
112 self.add(persister);
113 }
114}
115
116const SESSION_COOKIE: &str = "laterite_session";
117
118#[derive(Clone)]
120pub(crate) struct AdminState {
121 auth: AuthService,
122 db: Db,
123 nav: Arc<Vec<NavLink>>,
124 settings: Arc<Vec<settings::SettingsItem>>,
125 permissions: Arc<Vec<Permission>>,
126 admin_path: Arc<str>,
130 secure_cookie: bool,
131 origin: Arc<str>,
133 timezone: Tz,
134 default_locale: Arc<str>,
138 catalogs: Arc<CatalogStore>,
141 app_name: String,
144 brand_cache: Arc<RwLock<Option<String>>>,
148 field_types: Arc<field::FieldRegistry>,
151 column_types: Arc<list::ColumnRegistry>,
154 plugin_defined: Arc<laterite_core::Registry>,
157 assets: Arc<AssetRegistry>,
159 pickers: Arc<picker::PickerRegistry>,
162 overrides: Arc<dyn field::OverrideResolver>,
166}
167
168impl AdminState {
169 #[cfg(test)]
170 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
171 Self {
172 auth,
173 db,
174 nav: Arc::new(Vec::new()),
175 settings: Arc::new(Vec::new()),
176 permissions: Arc::new(builtin_permissions()),
177 admin_path: Arc::from("/admin"),
178 secure_cookie: false,
179 origin: Arc::from(""),
180 timezone: Tz::UTC,
181 default_locale: Arc::from("en"),
182 catalogs: Arc::new(CatalogStore::default()),
183 app_name: "Laterite".to_string(),
184 brand_cache: Arc::new(RwLock::new(None)),
185 field_types: Arc::new(field::builtin_registry()),
186 column_types: Arc::new(list::builtin_column_registry()),
187 plugin_defined: Arc::new(laterite_core::Registry::new()),
188 assets: Arc::new(builtin_assets()),
189 pickers: Arc::new(picker::PickerRegistry::new()),
190 overrides: Arc::new(field::NoOverrides),
191 }
192 }
193
194 async fn brand(&self) -> String {
198 {
199 let cached = self.brand_cache.read().unwrap().clone();
200 if let Some(name) = cached {
201 return name;
202 }
203 }
204 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
205 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
206 _ => self.app_name.clone(),
207 };
208 *self.brand_cache.write().unwrap() = Some(resolved.clone());
209 resolved
210 }
211
212 fn invalidate_brand(&self) {
214 *self.brand_cache.write().unwrap() = None;
215 }
216}
217
218#[derive(Clone)]
224#[non_exhaustive]
225pub struct AdminConfig {
226 pub secure_cookie: bool,
229 pub timezone: String,
233 pub locale: String,
237 pub app_name: String,
241 pub path: String,
245 pub origin: String,
250}
251
252impl Default for AdminConfig {
253 fn default() -> Self {
254 Self {
255 secure_cookie: false,
256 timezone: "UTC".to_string(),
257 locale: "en".to_string(),
258 app_name: "Laterite".to_string(),
259 path: "/admin".to_string(),
260 origin: String::new(),
261 }
262 }
263}
264
265#[derive(Clone)]
266struct NavLink {
267 label: Text,
268 path: String,
269 icon: Option<&'static str>,
272}
273
274#[derive(Clone)]
279pub(crate) struct Shell {
280 pub(crate) base: String,
284 brand: String,
288 nav: Vec<NavView>,
289 full_name: String,
290 initial: String,
291 tz: Tz,
295 i18n: Translator,
299 sidebar: Vec<settings::CategoryView>,
303 pub(crate) csrf_token: String,
307 pub(crate) flash: Vec<FlashLine>,
311 pub(crate) assets: Vec<PageAsset>,
315}
316
317#[derive(Clone)]
319pub(crate) struct PageAsset {
320 pub(crate) url: String,
321 pub(crate) css: bool,
322}
323
324#[derive(Clone)]
327pub(crate) struct FlashLine {
328 pub(crate) level: session::FlashLevel,
329 pub(crate) text: String,
330}
331
332impl Shell {
333 #[allow(clippy::too_many_arguments)]
334 fn new(
335 base: &str,
336 brand: String,
337 nav: &[NavLink],
338 user: &AuthenticatedUser,
339 default_tz: Tz,
340 sidebar: Vec<settings::CategoryView>,
341 active_nav: Option<&str>,
342 csrf_token: String,
343 flash: Vec<session::Flash>,
344 i18n: Translator,
345 ) -> Self {
346 let full_name = user.user.full_name();
347 let initial = full_name
348 .chars()
349 .next()
350 .map(|c| c.to_uppercase().to_string())
351 .unwrap_or_else(|| "?".to_string());
352 let flash = flash
355 .into_iter()
356 .map(|f| FlashLine {
357 level: f.level,
358 text: i18n.t(&f.text),
359 })
360 .collect();
361 let nav = nav
362 .iter()
363 .map(|n| NavView {
364 label: i18n.t(&n.label),
365 path: n.path.clone(),
366 active: active_nav == Some(n.path.as_str()),
367 icon: n.icon.map(|name| icons::svg(Some(name))).unwrap_or(""),
368 })
369 .collect();
370 Shell {
371 base: base.to_string(),
372 brand,
373 nav,
374 full_name,
375 initial,
376 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
377 i18n,
378 sidebar,
379 csrf_token,
380 flash,
381 assets: Vec::new(),
382 }
383 }
384
385 pub(crate) fn t(&self, source: &str) -> String {
388 self.i18n.t(&Text::dynamic(source))
389 }
390
391 pub(crate) fn tt(&self, text: &Text) -> String {
394 self.i18n.t(text)
395 }
396
397 pub(crate) fn locale(&self) -> &str {
399 self.i18n.locale()
400 }
401
402 pub(crate) fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
406 let mut text = Text::dynamic(source);
407 for (name, value) in args {
408 text = text.arg(*name, *value);
409 }
410 self.i18n.t(&text)
411 }
412
413 pub(crate) fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
416 let mut text = Text::dynamic(source);
417 for (name, value) in args {
418 text = text.arg(*name, *value);
419 }
420 self.i18n.t(&text)
421 }
422
423 #[cfg(test)]
424 pub(crate) fn test() -> Self {
425 Shell {
426 base: "/admin".to_string(),
427 brand: "Laterite".to_string(),
428 nav: Vec::new(),
429 full_name: "Test Operator".to_string(),
430 initial: "T".to_string(),
431 tz: Tz::UTC,
432 i18n: Translator::new("en"),
433 sidebar: Vec::new(),
434 csrf_token: "test-csrf-token".to_string(),
435 flash: Vec::new(),
436 assets: Vec::new(),
437 }
438 }
439}
440
441fn settings_context(
448 visible: &[settings::SettingsItem],
449 admin_path: &str,
450 path: &str,
451) -> (bool, Option<String>) {
452 let settings_root = format!("{admin_path}/settings");
453 if path == settings_root {
454 (true, None)
455 } else if let Some(code) = path.strip_prefix(&format!("{settings_root}/")) {
456 (true, Some(code.to_string()))
457 } else {
458 let active = visible
462 .iter()
463 .filter_map(|i| {
464 i.link
465 .as_deref()
466 .map(|link| (format!("{admin_path}{link}"), &i.code))
467 })
468 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
469 .max_by_key(|(link, _)| link.len())
470 .map(|(_, code)| code.clone());
471 (active.is_some(), active)
472 }
473}
474
475fn active_nav_path(
483 nav: &[NavLink],
484 admin_path: &str,
485 in_settings_context: bool,
486 path: &str,
487) -> Option<String> {
488 if in_settings_context {
489 return Some(format!("{admin_path}/settings"));
490 }
491 nav.iter()
492 .filter(|n| {
493 path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
494 })
495 .max_by_key(|n| n.path.len())
496 .map(|n| n.path.clone())
497}
498
499fn resolve_nav_context(
504 nav: &[NavLink],
505 items: &[settings::SettingsItem],
506 admin_path: &str,
507 perms: &PermissionSet,
508 path: &str,
509 tr: &Translator,
510) -> (Vec<settings::CategoryView>, Option<String>) {
511 let visible = visible_settings(items, perms);
512 let (in_context, active) = settings_context(&visible, admin_path, path);
513 let sidebar = if in_context {
514 settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr)
515 } else {
516 Vec::new()
517 };
518 let active_nav = active_nav_path(nav, admin_path, in_context, path);
519 (sidebar, active_nav)
520}
521
522fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
526 preference
527 .and_then(|name| name.parse::<Tz>().ok())
528 .unwrap_or(default_tz)
529}
530
531fn zone_offered(name: &str) -> bool {
533 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
534}
535
536fn base_lang(tag: &str) -> String {
539 tag.split(['-', '_'])
540 .next()
541 .unwrap_or(tag)
542 .to_ascii_lowercase()
543}
544
545fn is_serveable(base: &str, serveable: &[String]) -> bool {
551 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
552}
553
554fn default_locale(configured: &str, serveable: &[String]) -> String {
557 let base = base_lang(configured);
558 if is_serveable(&base, serveable) {
559 base
560 } else {
561 "en".to_string()
562 }
563}
564
565fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
569 let mut out = vec!["en".to_string()];
570 out.extend(catalogs.locales());
571 out
572}
573
574fn locale_name(code: &str) -> String {
579 if code == "en" {
580 "English".to_string()
581 } else {
582 code.to_string()
583 }
584}
585
586fn parse_accept_language(header: &str) -> Vec<String> {
589 let mut items: Vec<(f32, String)> = header
590 .split(',')
591 .filter_map(|part| {
592 let mut bits = part.split(';');
593 let tag = bits.next()?.trim();
594 if tag.is_empty() || tag == "*" {
595 return None;
596 }
597 let q = bits
598 .find_map(|p| p.trim().strip_prefix("q="))
599 .and_then(|q| q.parse::<f32>().ok())
600 .unwrap_or(1.0);
601 Some((q, tag.to_string()))
602 })
603 .collect();
604 items.sort_by(|a, b| b.0.total_cmp(&a.0));
606 items.into_iter().map(|(_, tag)| tag).collect()
607}
608
609fn resolve_locale_chain(
615 preference: Option<&str>,
616 accept_language: Option<&str>,
617 default: &str,
618 serveable: &[String],
619) -> Vec<String> {
620 let mut chain: Vec<String> = Vec::new();
621 let consider = |tag: &str, chain: &mut Vec<String>| {
622 let base = base_lang(tag);
623 if is_serveable(&base, serveable) && !chain.contains(&base) {
624 chain.push(base);
625 }
626 };
627 if let Some(pref) = preference {
628 consider(pref, &mut chain);
629 }
630 if let Some(header) = accept_language {
631 for tag in parse_accept_language(header) {
632 consider(&tag, &mut chain);
633 }
634 }
635 consider(default, &mut chain);
636 consider("en", &mut chain);
639 chain
640}
641
642fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
646 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
647 let serveable = state.catalogs.locales();
648 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
649 Translator::with_chain(chain, state.catalogs.clone())
650}
651
652#[derive(Serialize)]
655pub struct Resource {
656 pub base_path: String,
662 pub nav_label: Text,
664 pub list: list::ListConfig,
665 pub form: Option<form::FormConfig>,
666 pub permission: Option<String>,
673}
674
675#[derive(Clone, Serialize)]
680pub struct Permission {
681 pub code: String,
682 pub label: Text,
684 pub group: Text,
685}
686
687fn builtin_permissions() -> Vec<Permission> {
690 vec![
691 Permission {
692 code: "backend.manage_users".to_string(),
693 label: "Manage backend users".into(),
694 group: "Backend".into(),
695 },
696 Permission {
697 code: "backend.manage_roles".to_string(),
698 label: "Manage roles".into(),
699 group: "Backend".into(),
700 },
701 Permission {
702 code: "backend.manage_branding".to_string(),
703 label: "Manage branding".into(),
704 group: "Backend".into(),
705 },
706 Permission {
707 code: plugins::MANAGE_PERMISSION.to_string(),
708 label: "Manage plugins".into(),
709 group: "Backend".into(),
710 },
711 Permission {
712 code: "backend.view_audit_log".to_string(),
713 label: "View the audit log".into(),
714 group: "Backend".into(),
715 },
716 ]
717}
718
719pub fn descriptor_sources() -> Vec<String> {
725 let mut out = Vec::new();
726 out.extend(laterite_core::collect_sources(&builtin_resources()));
727 out.extend(laterite_core::collect_sources(&builtin_settings()));
728 out.extend(laterite_core::collect_sources(&builtin_permissions()));
729 out.sort();
730 out.dedup();
731 out
732}
733
734pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
749 builtin_modules().iter().map(|m| m.migrations()).collect()
750}
751
752pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
756 vec![
757 Box::new(laterite_auth::AuthModule),
758 Box::new(settings::SettingsModule),
759 Box::new(plugins::PluginsModule),
760 ]
761}
762
763#[derive(Default)]
787pub struct Contributions {
788 pub resources: Vec<Resource>,
790 pub settings: Vec<settings::SettingsItem>,
792 pub permissions: Vec<Permission>,
794 pub picker_sources: Vec<picker::PickerSourceReg>,
796 pub persisters: Vec<persist::PersisterReg>,
798 pub listeners: Vec<laterite_core::ModelListenerReg>,
800 pub column_types: Vec<list::ColumnTypeReg>,
802 pub screens: Vec<routes::ScreenReg>,
804 pub public_routes: Vec<routes::PublicRouteReg>,
806 pub plugin_defined: Arc<laterite_core::Registry>,
811}
812
813pub fn router(
814 auth: AuthService,
815 db: Db,
816 contributions: Contributions,
817 config: AdminConfig,
818 catalogs: Arc<CatalogStore>,
819) -> Router {
820 let Contributions {
821 resources: app_resources,
822 settings: app_settings,
823 permissions: app_permissions,
824 picker_sources: app_picker_sources,
825 persisters: app_persisters,
826 listeners: app_listeners,
827 column_types: app_column_types,
828 screens: app_screens,
829 public_routes: app_public,
830 plugin_defined,
831 } = contributions;
832 let admin_path = normalize_path(&config.path);
833
834 let mut resources = builtin_resources();
835 let mut app_resources = app_resources;
836 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
840 prefix_resource(&admin_path, resource);
841 }
842
843 let mut settings = builtin_settings();
844 settings.extend(app_settings);
845 let mut permissions = builtin_permissions();
846 permissions.extend(app_permissions);
847
848 let mut nav = vec![NavLink {
852 label: "Dashboard".into(),
853 path: admin_path.clone(),
854 icon: Some("layout-dashboard"),
855 }];
856 for resource in &app_resources {
857 nav.push(NavLink {
858 label: resource.nav_label.clone(),
859 path: resource.base_path.clone(),
860 icon: None,
861 });
862 }
863 for reg in &app_screens {
866 if let Some(label) = ®.nav_label {
867 nav.push(NavLink {
868 label: label.clone(),
869 path: format!("{admin_path}{}", reg.base_path),
870 icon: None,
871 });
872 }
873 }
874 nav.push(NavLink {
875 label: "Settings".into(),
876 path: format!("{admin_path}/settings"),
877 icon: Some("settings"),
878 });
879 resources.extend(app_resources);
880
881 let app_name = if config.app_name.trim().is_empty() {
882 "Laterite".to_string()
883 } else {
884 config.app_name.clone()
885 };
886
887 let mut pickers = picker::PickerRegistry::new();
890 for reg in app_picker_sources {
891 let name = reg.name.clone();
892 if !field::is_name(&name, true) {
893 panic!("invalid picker source name `{name}`");
894 }
895 if pickers.insert(name.clone(), reg).is_some() {
896 panic!("duplicate picker source `{name}`");
897 }
898 }
899
900 let pickers = Arc::new(pickers);
904 let mut field_types = field::builtin_registry();
905 field_types.insert(
906 "reference".to_string(),
907 Arc::new(field::RefPickerField::new(pickers.clone())),
908 );
909
910 let mut persisters = persist::PersisterRegistry::new();
913 for reg in app_persisters {
914 let name = reg.name.clone();
915 if !field::is_name(&name, true) {
916 panic!("invalid persister name `{name}`");
917 }
918 if persisters.insert(name.clone(), reg.persister).is_some() {
919 panic!("duplicate persister `{name}`");
920 }
921 }
922
923 let mut column_types = list::builtin_column_registry();
927 for reg in app_column_types {
928 let name = reg.name().to_string();
929 if !field::is_name(&name, true) {
930 panic!("invalid column type name `{name}`");
931 }
932 if column_types.insert(name.clone(), reg.column_type).is_some() {
933 panic!("duplicate column type `{name}`");
934 }
935 }
936
937 let state = AdminState {
938 auth,
939 db,
940 nav: Arc::new(nav),
941 settings: Arc::new(settings),
942 permissions: Arc::new(permissions),
943 admin_path: Arc::from(admin_path.as_str()),
944 secure_cookie: config.secure_cookie,
945 origin: Arc::from(config.origin.trim_end_matches('/')),
946 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
947 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
948 catalogs,
949 app_name,
950 brand_cache: Arc::new(RwLock::new(None)),
951 field_types: Arc::new(field_types),
952 column_types: Arc::new(column_types),
953 plugin_defined,
954 assets: Arc::new(builtin_assets()),
955 pickers,
956 overrides: Arc::new(field::NoOverrides),
957 };
958
959 let mut protected = Router::new().route(&admin_path, get(dashboard));
960 for resource in &resources {
961 protected = protected.merge(mount_resource(
962 resource,
963 &state.field_types,
964 &persisters,
965 &app_listeners,
966 ));
967 }
968 for reg in &app_screens {
972 let base = format!("{admin_path}{}", reg.base_path);
973 let ctx = routes::RouteCtx::new(
974 state.db.clone(),
975 &base,
976 &admin_path,
977 &state.origin,
978 state.plugin_defined.clone(),
979 );
980 protected = protected.merge(guard_with_permission(
981 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
982 ®.permission,
983 ));
984 }
985
986 protected = protected.merge(guard_with_permission(
989 Router::new()
990 .route(
991 &format!("{admin_path}/roles/new"),
992 get(roles::new_form).post(roles::create),
993 )
994 .route(
995 &format!("{admin_path}/roles/{{id}}/edit"),
996 get(roles::edit_form).post(roles::update),
997 ),
998 "backend.manage_roles",
999 ));
1000 protected = protected.merge(guard_with_permission(
1003 Router::new().route(
1004 &format!("{admin_path}/users/{{id}}/edit"),
1005 get(users::edit_form).post(users::update),
1006 ),
1007 "backend.manage_users",
1008 ));
1009 protected = protected.merge(guard_with_permission(
1012 Router::new()
1013 .route(&format!("{admin_path}/plugins"), get(plugins::index))
1014 .route(
1015 &format!("{admin_path}/plugins/toggle"),
1016 post(plugins::toggle),
1017 ),
1018 plugins::MANAGE_PERMISSION,
1019 ));
1020 protected = protected
1021 .route(&format!("{admin_path}/settings"), get(settings_index))
1022 .route(
1023 &format!("{admin_path}/settings/{{code}}"),
1024 get(settings_edit).post(settings_update),
1025 )
1026 .route(
1027 &format!("{admin_path}/preferences"),
1028 get(preferences_form).post(preferences_update),
1029 )
1030 .route(
1033 &format!("{admin_path}/pickers/{{source}}/search"),
1034 any(picker::search),
1035 )
1036 .route(
1037 &format!("{admin_path}/pickers/{{source}}/resolve"),
1038 any(picker::resolve),
1039 )
1040 .route(&format!("{admin_path}/logout"), post(logout));
1041
1042 protected
1043 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
1044 .route(
1048 &format!("{admin_path}/login"),
1049 get(login_form).post(login_submit),
1050 )
1051 .route(
1052 &format!("{admin_path}/setup"),
1053 get(setup_form).post(setup_submit),
1054 )
1055 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
1056 .merge(mount_public(
1060 &app_public,
1061 &state.db,
1062 &admin_path,
1063 &state.origin,
1064 &state.plugin_defined,
1065 ))
1066 .fallback(not_found_fallback)
1068 .layer(middleware::from_fn_with_state(
1071 state.clone(),
1072 enforce_origin,
1073 ))
1074 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
1075 handle_panic,
1076 ))
1077 .with_state(state)
1078}
1079
1080async fn not_found_fallback() -> AdminError {
1082 AdminError::NotFound
1083}
1084
1085fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
1087 let cause = err
1088 .downcast_ref::<&str>()
1089 .map(|s| s.to_string())
1090 .or_else(|| err.downcast_ref::<String>().cloned())
1091 .unwrap_or_else(|| "unknown panic".to_string());
1092 tracing::error!(panic = cause, "admin handler panicked");
1093 error::masked_500()
1094}
1095
1096pub fn normalize_path(path: &str) -> String {
1101 let trimmed = path.trim().trim_matches('/');
1102 if trimmed.is_empty() {
1103 "/admin".to_string()
1104 } else {
1105 format!("/{trimmed}")
1106 }
1107}
1108
1109fn mount_public(
1113 regs: &[routes::PublicRouteReg],
1114 db: &Db,
1115 admin_path: &str,
1116 origin: &str,
1117 plugin_defined: &Arc<laterite_core::Registry>,
1118) -> Router<AdminState> {
1119 let mut router = Router::new();
1120 for reg in regs {
1121 let ctx = routes::RouteCtx::new(
1122 db.clone(),
1123 ®.path,
1124 admin_path,
1125 origin,
1126 plugin_defined.clone(),
1127 );
1128 router = router.nest_service(
1133 ®.path,
1134 reg.route.mount(&ctx).fallback(not_found_fallback),
1135 );
1136 }
1137 router
1138}
1139
1140pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1143 let old = resource.base_path.clone();
1144 resource.base_path = base.to_string();
1145 if let Some(edit_base) = &mut resource.list.edit_base {
1146 if edit_base == &old {
1147 *edit_base = base.to_string();
1148 }
1149 }
1150 if let Some(form) = &mut resource.form {
1151 if form.base_path == old {
1152 form.base_path = base.to_string();
1153 }
1154 }
1155}
1156
1157fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1158 resource.base_path = format!("{admin_path}{}", resource.base_path);
1159 if let Some(edit_base) = &mut resource.list.edit_base {
1160 *edit_base = format!("{admin_path}{edit_base}");
1161 }
1162 if let Some(form) = &mut resource.form {
1163 form.base_path = format!("{admin_path}{}", form.base_path);
1164 }
1165}
1166
1167pub(crate) struct AdminAsset {
1169 pub mime: &'static str,
1170 pub cache: &'static str,
1171 pub bytes: &'static [u8],
1172}
1173
1174pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1177
1178pub(crate) fn page_assets(keys: &[&str], base: &str, registry: &AssetRegistry) -> Vec<PageAsset> {
1185 let mut seen = std::collections::HashSet::new();
1186 let mut out = Vec::new();
1187 for &key in keys {
1188 if !seen.insert(key) {
1189 continue;
1190 }
1191 match registry.get(key) {
1192 Some(asset) => out.push(PageAsset {
1193 url: format!("{base}/assets/{key}"),
1194 css: asset.mime.contains("css"),
1195 }),
1196 None => debug_assert!(false, "asset key `{key}` is not registered"),
1197 }
1198 }
1199 out
1200}
1201
1202const ASSET_IMMUTABLE: &str = "public, max-age=31536000, immutable";
1203
1204pub(crate) fn builtin_assets() -> AssetRegistry {
1208 let font = |bytes: &'static [u8]| AdminAsset {
1209 mime: "font/woff2",
1210 cache: ASSET_IMMUTABLE,
1211 bytes,
1212 };
1213 HashMap::from([
1214 (
1215 "laterite.css",
1216 AdminAsset {
1217 mime: "text/css; charset=utf-8",
1218 cache: "no-cache",
1219 bytes: include_bytes!("../assets/laterite.css"),
1220 },
1221 ),
1222 (
1223 "laterite.js",
1224 AdminAsset {
1225 mime: "text/javascript; charset=utf-8",
1226 cache: "no-cache",
1227 bytes: include_bytes!("../assets/laterite.js"),
1228 },
1229 ),
1230 (
1231 "vendor/htmx.min.js",
1232 AdminAsset {
1233 mime: "text/javascript; charset=utf-8",
1234 cache: "no-cache",
1235 bytes: include_bytes!("../assets/vendor/htmx.min.js"),
1236 },
1237 ),
1238 (
1239 "fields/ref-picker.js",
1240 AdminAsset {
1241 mime: "text/javascript; charset=utf-8",
1242 cache: "no-cache",
1243 bytes: include_bytes!("../assets/fields/ref-picker.js"),
1244 },
1245 ),
1246 (
1247 "fields/ref-picker.css",
1248 AdminAsset {
1249 mime: "text/css; charset=utf-8",
1250 cache: "no-cache",
1251 bytes: include_bytes!("../assets/fields/ref-picker.css"),
1252 },
1253 ),
1254 (
1255 "mark.svg",
1256 AdminAsset {
1257 mime: "image/svg+xml",
1258 cache: ASSET_IMMUTABLE,
1259 bytes: include_bytes!("../assets/mark.svg"),
1260 },
1261 ),
1262 (
1263 "mark.png",
1264 AdminAsset {
1265 mime: "image/png",
1266 cache: ASSET_IMMUTABLE,
1267 bytes: include_bytes!("../assets/mark.png"),
1268 },
1269 ),
1270 (
1271 "fonts/space-grotesk-500.woff2",
1272 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1273 ),
1274 (
1275 "fonts/space-grotesk-600.woff2",
1276 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1277 ),
1278 (
1279 "fonts/space-grotesk-700.woff2",
1280 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1281 ),
1282 (
1283 "fonts/ibm-plex-sans-400.woff2",
1284 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1285 ),
1286 (
1287 "fonts/ibm-plex-sans-600.woff2",
1288 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1289 ),
1290 (
1291 "fonts/ibm-plex-mono-400.woff2",
1292 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1293 ),
1294 (
1295 "fonts/ibm-plex-mono-600.woff2",
1296 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1297 ),
1298 ])
1299}
1300
1301async fn serve_asset(State(state): State<AdminState>, Path(path): Path<String>) -> Response {
1303 match state.assets.get(path.as_str()) {
1304 Some(asset) => (
1305 [
1306 (header::CONTENT_TYPE, asset.mime),
1307 (header::CACHE_CONTROL, asset.cache),
1308 ],
1309 asset.bytes,
1310 )
1311 .into_response(),
1312 None => not_found(),
1313 }
1314}
1315
1316fn mount_resource(
1321 resource: &Resource,
1322 field_types: &field::FieldRegistry,
1323 persisters: &persist::PersisterRegistry,
1324 listeners: &[laterite_core::ModelListenerReg],
1325) -> Router<AdminState> {
1326 if resource.form.is_some() && resource.permission.is_none() {
1331 panic!(
1332 "admin resource `{}` has a create/edit form but no permission; a write \
1333 resource must declare a permission",
1334 resource.base_path
1335 );
1336 }
1337 let base = resource.base_path.clone();
1338 let list_path = base.clone();
1339 let list_cfg = resource.list.clone();
1340 let mut router = Router::new().route(
1341 &base,
1342 get(
1343 move |State(state): State<AdminState>,
1344 Extension(shell): Extension<Shell>,
1345 Query(params): Query<list::ListParams>,
1346 Query(raw): Query<std::collections::HashMap<String, String>>,
1347 Extension(user): Extension<AuthenticatedUser>,
1348 headers: axum::http::HeaderMap| {
1349 let cfg = list_cfg.clone();
1350 let path = list_path.clone();
1351 async move {
1352 list::handle(&state, &cfg, &path, params, &raw, &user, shell, &headers).await
1353 }
1354 },
1355 ),
1356 );
1357
1358 let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
1359 router =
1360 router.route(
1361 &format!("{base}/export"),
1362 get(
1363 move |state: State<AdminState>,
1364 Query(raw): Query<std::collections::HashMap<String, String>>,
1365 Extension(user): Extension<AuthenticatedUser>,
1366 Extension(shell): Extension<Shell>,
1367 Extension(session): Extension<session::SessionHandle>| {
1368 let cfg = export_cfg.clone();
1369 let path = export_path.clone();
1370 async move {
1371 export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
1372 }
1373 },
1374 ),
1375 );
1376
1377 let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
1378 router = router.route(
1379 &format!("{base}/columns"),
1380 post(
1381 move |State(state): State<AdminState>,
1382 Extension(user): Extension<AuthenticatedUser>,
1383 Extension(session): Extension<session::SessionHandle>,
1384 headers: axum::http::HeaderMap,
1385 Form(pairs): Form<Vec<(String, String)>>| {
1386 let cfg = columns_cfg.clone();
1387 let path = columns_path.clone();
1388 async move {
1389 list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
1390 }
1391 },
1392 ),
1393 );
1394
1395 if resource.list.deletable {
1396 let persister: Arc<dyn persist::Persister> =
1400 match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
1401 Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
1402 panic!(
1403 "admin resource `{}`: unregistered persister `{key}`",
1404 resource.base_path
1405 )
1406 }),
1407 None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
1408 };
1409 let ctx = Arc::new(bulk::BulkContext {
1410 base_path: resource.base_path.clone(),
1411 entity: resource.list.entity.clone(),
1412 persister,
1413 listeners: listeners
1414 .iter()
1415 .filter(|reg| reg.matches(&resource.list.entity))
1416 .map(|reg| reg.listener.clone())
1417 .collect(),
1418 });
1419 router =
1420 router.route(
1421 &format!("{base}/delete"),
1422 post(
1423 move |state: State<AdminState>,
1424 user: Extension<AuthenticatedUser>,
1425 session: Extension<session::SessionHandle>,
1426 headers: axum::http::HeaderMap,
1427 form: Form<Vec<(String, String)>>| {
1428 let ctx = ctx.clone();
1429 async move {
1430 bulk::delete_handler(state, user, session, ctx, headers, form).await
1431 }
1432 },
1433 ),
1434 );
1435 }
1436
1437 if let Some(form_cfg) = resource.form.clone() {
1438 let prepared = Arc::new(
1441 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
1442 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1443 );
1444 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1445 router = router.route(
1446 &format!("{base}/new"),
1447 get(
1448 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1449 let pf = new_pf.clone();
1450 async move { form::new_form(&state, &pf, shell) }
1451 },
1452 )
1453 .post(
1454 move |State(state): State<AdminState>,
1455 Extension(shell): Extension<Shell>,
1456 Extension(user): Extension<AuthenticatedUser>,
1457 Extension(session): Extension<session::SessionHandle>,
1458 headers: axum::http::HeaderMap,
1459 Form(data): Form<HashMap<String, String>>| {
1460 let pf = create_pf.clone();
1461 async move {
1462 form::create(&state, &pf, data, shell, &user, &session, &headers).await
1463 }
1464 },
1465 ),
1466 );
1467
1468 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1469 router = router.route(
1470 &format!("{base}/{{id}}/edit"),
1471 get(
1472 move |State(state): State<AdminState>,
1473 Extension(shell): Extension<Shell>,
1474 Path(id): Path<String>| {
1475 let pf = edit_pf.clone();
1476 async move { form::edit_form(&state, &pf, id, shell).await }
1477 },
1478 )
1479 .post(
1480 move |State(state): State<AdminState>,
1481 Extension(shell): Extension<Shell>,
1482 Extension(user): Extension<AuthenticatedUser>,
1483 Extension(session): Extension<session::SessionHandle>,
1484 Path(id): Path<String>,
1485 headers: axum::http::HeaderMap,
1486 Form(data): Form<HashMap<String, String>>| {
1487 let pf = update_pf.clone();
1488 async move {
1489 form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
1490 }
1491 },
1492 ),
1493 );
1494 }
1495
1496 if let Some(permission) = &resource.permission {
1498 router = guard_with_permission(router, permission);
1499 }
1500 router
1501}
1502
1503fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
1508 let needed: Arc<str> = Arc::from(permission);
1509 router.route_layer(middleware::from_fn(
1510 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
1511 let needed = needed.clone();
1512 async move {
1513 if user.allows(&needed) {
1514 next.run(req).await
1515 } else {
1516 forbidden()
1517 }
1518 }
1519 },
1520 ))
1521}
1522
1523async fn require_auth(
1529 State(state): State<AdminState>,
1530 jar: CookieJar,
1531 request: Request,
1532 next: Next,
1533) -> Response {
1534 let login = format!("{}/login", state.admin_path);
1535 let token = match jar.get(SESSION_COOKIE) {
1536 Some(cookie) => cookie.value().to_string(),
1537 None => return Redirect::to(&login).into_response(),
1538 };
1539 let resolved = match state.auth.resolve_session(&token).await {
1540 Ok(resolved) => resolved,
1541 Err(_) => return Redirect::to(&login).into_response(),
1542 };
1543 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
1544
1545 let mut request = request;
1550 let safe = session::is_safe_method(request.method());
1551 if !safe {
1552 let (parts, body) = request.into_parts();
1553 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
1554 .await
1555 .unwrap_or_default();
1556 let submitted = session::submitted_token(&parts.headers, &bytes);
1557 if !session::token_matches(&handle.csrf_token(), submitted.as_deref()) {
1558 tracing::warn!(
1559 user_id = resolved.identity.user.id,
1560 "admin CSRF check failed"
1561 );
1562 return error::csrf_rejected();
1563 }
1564 request = Request::from_parts(parts, Body::from(bytes));
1565 }
1566
1567 let flash = if safe {
1570 handle.take_flash()
1571 } else {
1572 Vec::new()
1573 };
1574 let user = resolved.identity;
1575 let path = request.uri().path().to_string();
1576 let accept_language = request
1580 .headers()
1581 .get("accept-language")
1582 .and_then(|v| v.to_str().ok());
1583 let serveable = state.catalogs.locales();
1584 let chain = resolve_locale_chain(
1585 user.user.locale.as_deref(),
1586 accept_language,
1587 &state.default_locale,
1588 &serveable,
1589 );
1590 let i18n = Translator::with_chain(chain, state.catalogs.clone());
1591 let translator = i18n.clone();
1594 let (sidebar, active_nav) = resolve_nav_context(
1595 &state.nav,
1596 &state.settings,
1597 &state.admin_path,
1598 &user.permissions,
1599 &path,
1600 &i18n,
1601 );
1602 let brand = state.brand().await;
1603 let shell = Shell::new(
1604 &state.admin_path,
1605 brand,
1606 &state.nav,
1607 &user,
1608 state.timezone,
1609 sidebar,
1610 active_nav.as_deref(),
1611 handle.csrf_token(),
1612 flash,
1613 i18n,
1614 );
1615 request.extensions_mut().insert(user);
1616 request.extensions_mut().insert(shell);
1617 request.extensions_mut().insert(handle.clone());
1618 let mut response = next.run(request).await;
1619
1620 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
1624 response = error::localized_error(meta.kind, &translator);
1625 }
1626
1627 if let Some(blob) = handle.dirty_blob() {
1630 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
1631 tracing::error!(error = %e, "persisting admin session failed");
1632 }
1633 }
1634 response
1635}
1636
1637const MAX_FORM_BYTES: usize = 1024 * 1024;
1640
1641async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
1646 if !session::is_safe_method(request.method())
1647 && !session::origin_ok(request.headers(), &state.origin)
1648 {
1649 tracing::warn!(
1650 path = request.uri().path(),
1651 "admin CSRF origin check failed"
1652 );
1653 return error::csrf_rejected();
1654 }
1655 next.run(request).await
1656}
1657
1658async fn login_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
1659 match state.auth.has_any_operator().await {
1661 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
1662 Ok(true) => render(LoginTemplate {
1663 base: state.admin_path.to_string(),
1664 brand: state.brand().await,
1665 error: None,
1666 i18n: pre_auth_translator(&state, &headers),
1667 }),
1668 Err(_) => render_error(),
1669 }
1670}
1671
1672#[derive(Deserialize)]
1673struct LoginForm {
1674 username: String,
1675 password: String,
1676}
1677
1678async fn login_submit(
1679 State(state): State<AdminState>,
1680 jar: CookieJar,
1681 headers: HeaderMap,
1682 Form(form): Form<LoginForm>,
1683) -> Response {
1684 match state
1685 .auth
1686 .authenticate(&form.username, &form.password, &RequestContext::default())
1687 .await
1688 {
1689 Ok(session) => {
1690 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
1691 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
1692 }
1693 Err(_) => {
1694 let i18n = pre_auth_translator(&state, &headers);
1695 render(LoginTemplate {
1696 base: state.admin_path.to_string(),
1697 brand: state.brand().await,
1698 error: Some(i18n.t(&t!("Invalid username or password."))),
1699 i18n,
1700 })
1701 }
1702 }
1703}
1704
1705fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
1708 Cookie::build((SESSION_COOKIE, token))
1709 .path(admin_path.to_string())
1710 .http_only(true)
1711 .secure(secure)
1712 .same_site(SameSite::Lax)
1713 .build()
1714}
1715
1716#[derive(Deserialize)]
1717struct SetupForm {
1718 username: String,
1719 first_name: String,
1720 last_name: String,
1721 email: String,
1722 password: String,
1723 timezone: String,
1724}
1725
1726async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
1729 match state.auth.has_any_operator().await {
1730 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1731 Ok(false) => render(setup_view(
1732 &state.admin_path,
1733 state.brand().await,
1734 state.timezone,
1735 None,
1736 pre_auth_translator(&state, &headers),
1737 )),
1738 Err(_) => render_error(),
1739 }
1740}
1741
1742async fn setup_submit(
1743 State(state): State<AdminState>,
1744 jar: CookieJar,
1745 headers: HeaderMap,
1746 Form(form): Form<SetupForm>,
1747) -> Response {
1748 match state.auth.has_any_operator().await {
1750 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1751 Ok(false) => {}
1752 Err(_) => return render_error(),
1753 }
1754
1755 let username = form.username.trim();
1756 let email = form.email.trim();
1757 let first_name = form.first_name.trim();
1758 let last_name = form.last_name.trim();
1759 let tz = form.timezone.trim();
1760 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
1761 {
1762 return render(setup_view(
1763 &state.admin_path,
1764 state.brand().await,
1765 state.timezone,
1766 Some(t!(
1767 "Username, first name, email, and password are all required."
1768 )),
1769 pre_auth_translator(&state, &headers),
1770 ));
1771 }
1772 if tz.parse::<Tz>().is_err() {
1774 return render(setup_view(
1775 &state.admin_path,
1776 state.brand().await,
1777 state.timezone,
1778 Some(t!("That is not a recognised timezone.")),
1779 pre_auth_translator(&state, &headers),
1780 ));
1781 }
1782
1783 let new = NewOperator {
1784 username,
1785 email,
1786 first_name,
1787 last_name: (!last_name.is_empty()).then_some(last_name),
1788 password: &form.password,
1789 timezone: Some(tz),
1790 };
1791 if state.auth.create_superuser(new).await.is_err() {
1792 return render(setup_view(
1793 &state.admin_path,
1794 state.brand().await,
1795 state.timezone,
1796 Some(t!(
1797 "Could not create the account. The username or email may already be taken."
1798 )),
1799 pre_auth_translator(&state, &headers),
1800 ));
1801 }
1802
1803 match state
1805 .auth
1806 .authenticate(username, &form.password, &RequestContext::default())
1807 .await
1808 {
1809 Ok(session) => {
1810 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
1811 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
1812 }
1813 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1814 }
1815}
1816
1817fn setup_view(
1820 admin_path: &str,
1821 brand: String,
1822 default_tz: Tz,
1823 error: Option<Text>,
1824 i18n: Translator,
1825) -> SetupTemplate {
1826 let default_name = default_tz.name();
1827 let zones = TZ_VARIANTS
1828 .iter()
1829 .filter(|tz| zone_offered(tz.name()))
1830 .map(|tz| TzOption {
1831 name: tz.name().to_string(),
1832 selected: tz.name() == default_name,
1833 })
1834 .collect();
1835 SetupTemplate {
1836 base: admin_path.to_string(),
1837 brand,
1838 zones,
1839 error: error.map(|e| i18n.t(&e)),
1840 i18n,
1841 }
1842}
1843
1844async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
1845 if let Some(cookie) = jar.get(SESSION_COOKIE) {
1846 let _ = state.auth.logout(cookie.value()).await;
1847 }
1848 let removal = Cookie::build((SESSION_COOKIE, ""))
1849 .path(state.admin_path.to_string())
1850 .build();
1851 (
1852 jar.remove(removal),
1853 Redirect::to(&format!("{}/login", state.admin_path)),
1854 )
1855 .into_response()
1856}
1857
1858async fn dashboard(
1859 Extension(shell): Extension<Shell>,
1860 Extension(user): Extension<AuthenticatedUser>,
1861) -> Response {
1862 render(DashboardTemplate {
1863 username: user.user.username,
1864 shell,
1865 })
1866}
1867
1868fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
1871 match &item.permission {
1872 None => true,
1873 Some(p) => perms.allows(p),
1874 }
1875}
1876
1877fn visible_settings(
1881 items: &[settings::SettingsItem],
1882 perms: &PermissionSet,
1883) -> Vec<settings::SettingsItem> {
1884 items
1885 .iter()
1886 .filter(|item| operator_can_see(item, perms))
1887 .cloned()
1888 .collect()
1889}
1890
1891async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
1892 settings::index(shell)
1893}
1894
1895async fn settings_edit(
1896 State(state): State<AdminState>,
1897 Extension(shell): Extension<Shell>,
1898 Extension(user): Extension<AuthenticatedUser>,
1899 Path(code): Path<String>,
1900) -> Response {
1901 let items = visible_settings(&state.settings, &user.permissions);
1904 match items
1905 .iter()
1906 .find(|item| item.code == code && item.link.is_none())
1907 {
1908 Some(item) => settings::edit_form(&state, item, shell).await,
1909 None => not_found(),
1910 }
1911}
1912
1913async fn settings_update(
1914 State(state): State<AdminState>,
1915 Extension(shell): Extension<Shell>,
1916 Extension(user): Extension<AuthenticatedUser>,
1917 Extension(session): Extension<session::SessionHandle>,
1918 Path(code): Path<String>,
1919 Form(data): Form<HashMap<String, String>>,
1920) -> Response {
1921 let items = visible_settings(&state.settings, &user.permissions);
1922 match items
1923 .iter()
1924 .find(|item| item.code == code && item.link.is_none())
1925 {
1926 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
1927 None => not_found(),
1928 }
1929}
1930
1931async fn preferences_form(
1933 State(state): State<AdminState>,
1934 Extension(shell): Extension<Shell>,
1935 Extension(user): Extension<AuthenticatedUser>,
1936) -> Response {
1937 render(preferences_view(
1938 &shell,
1939 &user,
1940 state.timezone,
1941 &state.default_locale,
1942 &offered_locales(&state.catalogs),
1943 None,
1944 ))
1945}
1946
1947#[derive(Deserialize)]
1948struct PreferencesForm {
1949 timezone: String,
1950 #[serde(default)]
1952 locale: String,
1953}
1954
1955async fn preferences_update(
1956 State(state): State<AdminState>,
1957 Extension(shell): Extension<Shell>,
1958 Extension(user): Extension<AuthenticatedUser>,
1959 Extension(session): Extension<session::SessionHandle>,
1960 Form(form): Form<PreferencesForm>,
1961) -> Response {
1962 let offered = offered_locales(&state.catalogs);
1963 let tz = form.timezone.trim();
1965 let tz_stored = if tz.is_empty() {
1966 None
1967 } else if tz.parse::<Tz>().is_ok() {
1968 Some(tz)
1969 } else {
1970 return render(preferences_view(
1971 &shell,
1972 &user,
1973 state.timezone,
1974 &state.default_locale,
1975 &offered,
1976 Some(t!("That is not a recognised timezone.")),
1977 ));
1978 };
1979 let loc = form.locale.trim();
1980 let loc_stored = if loc.is_empty() {
1981 None
1982 } else if offered.iter().any(|l| l == loc) {
1983 Some(loc)
1984 } else {
1985 return render(preferences_view(
1986 &shell,
1987 &user,
1988 state.timezone,
1989 &state.default_locale,
1990 &offered,
1991 Some(t!("That is not a supported language.")),
1992 ));
1993 };
1994 if state
1995 .auth
1996 .set_user_timezone(user.user.id, tz_stored)
1997 .await
1998 .is_err()
1999 {
2000 return render_error();
2001 }
2002 match state.auth.set_user_locale(user.user.id, loc_stored).await {
2003 Ok(()) => {
2004 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
2005 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
2006 }
2007 Err(_) => render_error(),
2008 }
2009}
2010
2011fn preferences_view(
2015 shell: &Shell,
2016 user: &AuthenticatedUser,
2017 default_tz: Tz,
2018 default_locale: &str,
2019 offered: &[String],
2020 error: Option<Text>,
2021) -> PreferencesTemplate {
2022 let current = user.user.timezone.as_deref();
2023 let zones = TZ_VARIANTS
2024 .iter()
2025 .filter(|tz| zone_offered(tz.name()))
2026 .map(|tz| TzOption {
2027 name: tz.name().to_string(),
2028 selected: current == Some(tz.name()),
2029 })
2030 .collect();
2031 let current_locale = user.user.locale.as_deref();
2032 let locales = offered
2033 .iter()
2034 .map(|code| LocaleOption {
2035 name: locale_name(code),
2036 selected: current_locale == Some(code.as_str()),
2037 code: code.clone(),
2038 })
2039 .collect();
2040 PreferencesTemplate {
2041 shell: shell.clone(),
2042 zones,
2043 effective_tz: shell.tz.name().to_string(),
2044 default_tz: default_tz.name().to_string(),
2045 inherits: current.is_none(),
2046 locales,
2047 default_locale: locale_name(default_locale),
2048 inherits_locale: current_locale.is_none(),
2049 error: error.map(|e| shell.tt(&e)),
2050 }
2051}
2052
2053fn builtin_resources() -> Vec<Resource> {
2055 vec![
2056 Resource {
2057 base_path: "/users".to_string(),
2058 nav_label: "Backend Users".into(),
2059 list: backend_users_list_config(),
2060 form: None,
2061 permission: Some("backend.manage_users".to_string()),
2062 },
2063 Resource {
2064 base_path: "/roles".to_string(),
2065 nav_label: "Roles".into(),
2066 list: roles_list_config(),
2067 form: None,
2070 permission: Some("backend.manage_roles".to_string()),
2071 },
2072 Resource {
2073 base_path: "/audit-log".to_string(),
2074 nav_label: "Audit Log".into(),
2075 list: audit_log_list_config(),
2076 form: None,
2078 permission: Some("backend.view_audit_log".to_string()),
2079 },
2080 ]
2081}
2082
2083fn builtin_settings() -> Vec<settings::SettingsItem> {
2087 vec![
2088 settings::SettingsItem {
2089 code: "backend.administrators".to_string(),
2090 label: "Administrators".into(),
2091 description: "Manage backend administrator accounts.".into(),
2092 category: "Users".into(),
2093 order: 10,
2094 icon: Some("users".to_string()),
2095 permission: Some("backend.manage_users".to_string()),
2096 link: Some("/users".to_string()),
2097 fields: Vec::new(),
2098 },
2099 settings::SettingsItem {
2100 code: "backend.roles".to_string(),
2101 label: "Roles".into(),
2102 description: "Manage roles and their permissions.".into(),
2103 category: "Users".into(),
2104 order: 20,
2105 icon: Some("shield".to_string()),
2106 permission: Some("backend.manage_roles".to_string()),
2107 link: Some("/roles".to_string()),
2108 fields: Vec::new(),
2109 },
2110 settings::brand::settings_item(),
2111 settings::SettingsItem {
2112 code: "backend.plugins".to_string(),
2113 label: "Plugins".into(),
2114 description: "Enable or disable installed plugins.".into(),
2115 category: "System".into(),
2116 order: 10,
2117 icon: Some("plug".to_string()),
2118 permission: Some(plugins::MANAGE_PERMISSION.to_string()),
2119 link: Some("/plugins".to_string()),
2120 fields: Vec::new(),
2121 },
2122 settings::SettingsItem {
2123 code: "backend.audit_log".to_string(),
2124 label: "Audit Log".into(),
2125 description: "Review the record of administrative changes.".into(),
2126 category: "System".into(),
2127 order: 20,
2128 icon: Some("history".to_string()),
2129 permission: Some("backend.view_audit_log".to_string()),
2130 link: Some("/audit-log".to_string()),
2131 fields: Vec::new(),
2132 },
2133 ]
2134}
2135
2136fn backend_users_list_config() -> list::ListConfig {
2137 list::ListConfig {
2138 entity: "backend_users".to_string(),
2139 title: "Backend Users".into(),
2140 columns: vec![
2141 list::ListColumn::new("username", "Username"),
2142 list::ListColumn::new("email", "Email"),
2143 list::ListColumn::new("first_name", "First name"),
2144 list::ListColumn::new("last_name", "Last name"),
2145 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
2146 list::ListColumn::new("is_active", "Active").yes_no(),
2147 list::ListColumn::new("created_at", "Created").datetime(),
2148 ],
2149 order_by: "created_at".to_string(),
2150 edit_base: Some("/users".to_string()),
2153 filters: vec![
2154 list::ListFilter::boolean("is_active", "Active"),
2155 list::ListFilter::boolean("is_superuser", "Superuser"),
2156 ],
2157 ..Default::default()
2161 }
2162}
2163
2164fn roles_list_config() -> list::ListConfig {
2165 list::ListConfig {
2166 entity: "backend_roles".to_string(),
2167 title: "Roles".into(),
2168 columns: vec![
2169 list::ListColumn::new("code", "Code"),
2170 list::ListColumn::new("name", "Name"),
2171 list::ListColumn::new("created_at", "Created").datetime(),
2172 ],
2173 order_by: "created_at".to_string(),
2174 edit_base: Some("/roles".to_string()),
2175 creatable: true,
2176 deletable: true,
2177 ..Default::default()
2178 }
2179}
2180
2181fn audit_log_list_config() -> list::ListConfig {
2184 list::ListConfig {
2185 entity: "backend_audit_log".to_string(),
2186 title: "Audit Log".into(),
2187 columns: vec![
2188 list::ListColumn::new("created_at", "When").datetime(),
2189 list::ListColumn::new("actor_username", "Operator"),
2190 list::ListColumn::new("action", "Action"),
2191 list::ListColumn::new("target_type", "Target"),
2192 list::ListColumn::new("target_id", "Target ID"),
2193 ],
2194 order_by: "created_at".to_string(),
2195 per_page: 50,
2196 ..Default::default()
2198 }
2199}
2200
2201#[derive(Template)]
2202#[template(path = "login.html")]
2203struct LoginTemplate {
2204 base: String,
2206 brand: String,
2207 error: Option<String>,
2208 i18n: Translator,
2211}
2212
2213impl LoginTemplate {
2214 fn t(&self, source: &str) -> String {
2215 self.i18n.t(&Text::dynamic(source))
2216 }
2217 fn locale(&self) -> &str {
2218 self.i18n.locale()
2219 }
2220}
2221
2222#[derive(Template)]
2223#[template(path = "dashboard.html")]
2224struct DashboardTemplate {
2225 shell: Shell,
2226 username: String,
2227}
2228
2229#[derive(Template)]
2230#[template(path = "setup.html")]
2231struct SetupTemplate {
2232 base: String,
2234 brand: String,
2235 zones: Vec<TzOption>,
2236 error: Option<String>,
2237 i18n: Translator,
2239}
2240
2241impl SetupTemplate {
2242 fn t(&self, source: &str) -> String {
2243 self.i18n.t(&Text::dynamic(source))
2244 }
2245 fn locale(&self) -> &str {
2246 self.i18n.locale()
2247 }
2248}
2249
2250#[derive(Template)]
2251#[template(path = "preferences.html")]
2252struct PreferencesTemplate {
2253 shell: Shell,
2254 zones: Vec<TzOption>,
2255 effective_tz: String,
2257 default_tz: String,
2259 inherits: bool,
2261 locales: Vec<LocaleOption>,
2262 default_locale: String,
2264 inherits_locale: bool,
2266 error: Option<String>,
2267}
2268
2269struct TzOption {
2270 name: String,
2271 selected: bool,
2272}
2273
2274struct LocaleOption {
2275 code: String,
2277 name: String,
2279 selected: bool,
2280}
2281
2282#[derive(Clone)]
2283struct NavView {
2284 label: String,
2285 path: String,
2286 active: bool,
2287 icon: &'static str,
2290}
2291
2292pub(crate) fn render<T: Template>(template: T) -> Response {
2294 match template.render() {
2295 Ok(html) => Html(html).into_response(),
2296 Err(e) => AdminError::from(e).into_response(),
2298 }
2299}
2300
2301pub(crate) fn render_error() -> Response {
2302 error::masked_500()
2303}
2304
2305pub(crate) fn not_found() -> Response {
2306 AdminError::NotFound.into_response()
2307}
2308
2309pub(crate) fn forbidden() -> Response {
2310 AdminError::Forbidden.into_response()
2311}
2312
2313#[cfg(test)]
2314mod tests {
2315 use super::*;
2316
2317 fn write_form() -> form::FormConfig {
2318 form::FormConfig {
2319 entity: "widgets".to_string(),
2320 title: "Widgets".into(),
2321 base_path: "/widgets".to_string(),
2322 id_field: "id".to_string(),
2323 fields: vec![],
2324 persist: None,
2325 timestamps: false,
2326 }
2327 }
2328
2329 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
2330 Resource {
2331 base_path: "/widgets".to_string(),
2332 nav_label: "Widgets".into(),
2333 list: backend_users_list_config(),
2334 form,
2335 permission: permission.map(str::to_string),
2336 }
2337 }
2338
2339 #[test]
2340 #[should_panic(expected = "must declare a permission")]
2341 fn write_resource_without_permission_aborts_boot() {
2342 let resource = resource_with(Some(write_form()), None);
2345 let _ = mount_resource(
2346 &resource,
2347 &field::builtin_registry(),
2348 &persist::PersisterRegistry::new(),
2349 &[],
2350 );
2351 }
2352
2353 #[test]
2354 fn read_only_resource_may_omit_permission() {
2355 let resource = resource_with(None, None);
2357 let _ = mount_resource(
2358 &resource,
2359 &field::builtin_registry(),
2360 &persist::PersisterRegistry::new(),
2361 &[],
2362 );
2363 }
2364
2365 #[test]
2366 fn shell_localizes_through_the_translator_and_falls_back() {
2367 let shell = Shell::test();
2369 assert_eq!(shell.t("Save"), "Save");
2370 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
2371 }
2372
2373 #[test]
2374 fn shell_tf_interpolates_integer_arguments() {
2375 let shell = Shell::test();
2376 assert_eq!(
2377 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
2378 "Page 2 of 5"
2379 );
2380 }
2381
2382 #[test]
2383 fn display_tz_prefers_a_valid_operator_preference() {
2384 assert_eq!(
2385 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
2386 Tz::Asia__Kolkata
2387 );
2388 }
2389
2390 #[test]
2391 fn display_tz_falls_back_when_unset_or_invalid() {
2392 let default = Tz::Europe__London;
2393 assert_eq!(resolve_display_tz(None, default), default);
2395 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
2397 assert_eq!(resolve_display_tz(Some(""), default), default);
2398 }
2399
2400 #[test]
2401 fn accept_language_orders_by_weight_and_drops_wildcard() {
2402 assert_eq!(
2403 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
2404 vec!["en-US", "en", "kn"]
2405 );
2406 assert_eq!(
2408 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
2409 vec!["kn", "en"]
2410 );
2411 }
2412
2413 #[test]
2414 fn locale_chain_prefers_operator_then_header_then_default() {
2415 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
2417 assert_eq!(
2420 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
2421 vec!["kn", "ta", "hi", "en"]
2422 );
2423 assert_eq!(
2425 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
2426 vec!["kn", "en", "ta"]
2427 );
2428 assert_eq!(
2430 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
2431 vec!["kn", "en"]
2432 );
2433 }
2434
2435 #[test]
2436 fn locale_chain_skips_unserveable_and_defaults_to_en() {
2437 let loaded = ["hi".to_string()];
2439 assert_eq!(
2442 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
2443 vec!["en"]
2444 );
2445 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
2447 assert_eq!(
2449 resolve_locale_chain(Some("hi"), None, "en", &loaded),
2450 vec!["hi", "en"]
2451 );
2452 assert_eq!(
2454 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
2455 vec![laterite_core::PSEUDO_LOCALE, "en"]
2456 );
2457 }
2458
2459 #[test]
2460 fn page_assets_dedupe_preserve_order_and_classify() {
2461 let mut reg = AssetRegistry::new();
2462 reg.insert(
2463 "a.js",
2464 AdminAsset {
2465 mime: "text/javascript; charset=utf-8",
2466 cache: "no-cache",
2467 bytes: b"",
2468 },
2469 );
2470 reg.insert(
2471 "b.css",
2472 AdminAsset {
2473 mime: "text/css; charset=utf-8",
2474 cache: "no-cache",
2475 bytes: b"",
2476 },
2477 );
2478 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®);
2481 assert_eq!(assets.len(), 2);
2482 assert_eq!(assets[0].url, "/admin/assets/a.js");
2483 assert!(!assets[0].css);
2484 assert_eq!(assets[1].url, "/admin/assets/b.css");
2485 assert!(assets[1].css);
2486 }
2487
2488 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
2492 laterite_core::testing::connect_test(&[]).await
2493 }
2494
2495 #[tokio::test]
2496 async fn builtin_migrations_create_the_admin_tables() {
2497 let (db, _guard) = empty_db().await;
2498 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
2499 .await
2500 .unwrap();
2501 for table in ["backend_users", "settings"] {
2505 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
2506 .fetch_optional(&db.pool)
2507 .await;
2508 assert!(
2509 probe.is_ok(),
2510 "{table} should exist after builtin_migrations"
2511 );
2512 }
2513 }
2514
2515 #[tokio::test]
2516 async fn brand_setting_overrides_config_and_blank_falls_back() {
2517 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
2518 let state = AdminState {
2519 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2520 db: db.clone(),
2521 nav: Arc::new(Vec::new()),
2522 settings: Arc::new(Vec::new()),
2523 permissions: Arc::new(builtin_permissions()),
2524 admin_path: Arc::from("/admin"),
2525 secure_cookie: false,
2526 origin: Arc::from(""),
2527 timezone: Tz::UTC,
2528 default_locale: Arc::from("en"),
2529 catalogs: Arc::new(CatalogStore::default()),
2530 app_name: "Configured Name".to_string(),
2531 brand_cache: Arc::new(RwLock::new(None)),
2532 field_types: Arc::new(field::builtin_registry()),
2533 column_types: Arc::new(list::builtin_column_registry()),
2534 plugin_defined: Arc::new(laterite_core::Registry::new()),
2535 assets: Arc::new(builtin_assets()),
2536 pickers: Arc::new(picker::PickerRegistry::new()),
2537 overrides: Arc::new(field::NoOverrides),
2538 };
2539
2540 assert_eq!(state.brand().await, "Configured Name");
2542
2543 settings::store::save(
2546 &db,
2547 &settings::BrandSetting {
2548 app_name: "Acme Corp".to_string(),
2549 },
2550 )
2551 .await
2552 .unwrap();
2553 state.invalidate_brand();
2554 assert_eq!(state.brand().await, "Acme Corp");
2555
2556 settings::store::save(
2558 &db,
2559 &settings::BrandSetting {
2560 app_name: " ".to_string(),
2561 },
2562 )
2563 .await
2564 .unwrap();
2565 state.invalidate_brand();
2566 assert_eq!(state.brand().await, "Configured Name");
2567 }
2568
2569 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
2570 settings::SettingsItem {
2571 code: code.to_string(),
2572 label: code.into(),
2573 description: String::new().into(),
2574 category: "General".into(),
2575 order: 1,
2576 icon: None,
2577 permission: permission.map(str::to_string),
2578 link: None,
2579 fields: Vec::new(),
2580 }
2581 }
2582
2583 #[test]
2584 fn settings_visibility_respects_permissions() {
2585 let items = vec![
2586 settings_item("public", None),
2587 settings_item("gated", Some("backend.manage_users")),
2588 ];
2589
2590 let none = PermissionSet::new(false, Vec::<String>::new());
2592 let codes: Vec<String> = visible_settings(&items, &none)
2593 .into_iter()
2594 .map(|i| i.code)
2595 .collect();
2596 assert_eq!(codes, ["public"]);
2597
2598 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
2600 assert_eq!(visible_settings(&items, &granted).len(), 2);
2601
2602 let superuser = PermissionSet::new(true, Vec::<String>::new());
2604 assert_eq!(visible_settings(&items, &superuser).len(), 2);
2605 }
2606
2607 #[test]
2608 fn context_sidebar_follows_settings_links() {
2609 let items = builtin_settings();
2610 let superuser = PermissionSet::new(true, Vec::<String>::new());
2611 let tr = Translator::new("en");
2612 let sidebar =
2613 |path: &str| resolve_nav_context(&[], &items, "/admin", &superuser, path, &tr).0;
2614 let active_path = |path: &str| -> Option<String> {
2615 sidebar(path)
2616 .into_iter()
2617 .flat_map(|g| g.items)
2618 .find(|i| i.active)
2619 .map(|i| i.path)
2620 };
2621
2622 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
2624 assert_eq!(
2625 active_path("/admin/roles/42/edit").as_deref(),
2626 Some("/admin/roles")
2627 );
2628 assert!(!sidebar("/admin/settings").is_empty());
2630 assert_eq!(active_path("/admin/settings"), None);
2631 assert_eq!(
2633 active_path("/admin/settings/backend.roles").as_deref(),
2634 Some("/admin/roles")
2635 );
2636 assert!(sidebar("/admin").is_empty());
2638 }
2639
2640 #[test]
2641 fn active_nav_lights_the_right_tab() {
2642 let nav = vec![
2643 NavLink {
2644 label: "Dashboard".into(),
2645 path: "/admin".to_string(),
2646 icon: Some("layout-dashboard"),
2647 },
2648 NavLink {
2649 label: "Pages".into(),
2650 path: "/admin/pages".to_string(),
2651 icon: None,
2652 },
2653 NavLink {
2654 label: "Settings".into(),
2655 path: "/admin/settings".to_string(),
2656 icon: Some("settings"),
2657 },
2658 ];
2659
2660 assert_eq!(
2662 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
2663 Some("/admin")
2664 );
2665 assert_eq!(
2667 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
2668 Some("/admin/pages")
2669 );
2670 assert_eq!(
2672 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
2673 None
2674 );
2675 assert_eq!(
2678 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
2679 None
2680 );
2681 assert_eq!(
2684 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
2685 Some("/admin/settings")
2686 );
2687 assert_eq!(
2688 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
2689 Some("/admin/settings")
2690 );
2691 }
2692}