1mod audit;
18pub mod bootstrap;
19mod error;
20pub mod field;
21pub mod form;
22pub mod html;
23mod icons;
24pub mod list;
25pub mod persist;
26pub mod picker;
27pub mod plugins;
28mod roles;
29mod routemap;
30pub mod routes;
31mod session;
32pub mod settings;
33mod sql;
34mod users;
35
36pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
37pub use error::AdminError;
38pub use session::{FlashLevel, SessionHandle};
39
40use std::collections::HashMap;
41use std::sync::{Arc, RwLock};
42
43use askama::Template;
44use axum::body::Body;
45use axum::extract::{Path, Query, Request, State};
46use axum::http::{header, HeaderMap};
47use axum::middleware::{self, Next};
48use axum::response::{Html, IntoResponse, Redirect, Response};
49use axum::routing::{any, get, post};
50use axum::{Extension, Form, Router};
51use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
52use chrono_tz::{Tz, TZ_VARIANTS};
53use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
54use laterite_core::{t, CatalogStore, Db, Text, Translator};
55use serde::{Deserialize, Serialize};
56
57pub trait AdminRegistry {
63 fn add_resource(&mut self, resource: Resource);
65 fn add_permission(&mut self, permission: Permission);
67 fn add_settings(&mut self, item: settings::SettingsItem);
69 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
71 fn add_persister(&mut self, persister: persist::PersisterReg);
73 fn add_screen(&mut self, screen: routes::ScreenReg);
76 fn add_public_route(&mut self, route: routes::PublicRouteReg);
79}
80
81impl AdminRegistry for laterite_core::Registry {
82 fn add_resource(&mut self, resource: Resource) {
83 self.add(resource);
84 }
85 fn add_permission(&mut self, permission: Permission) {
86 self.add(permission);
87 }
88 fn add_settings(&mut self, item: settings::SettingsItem) {
89 self.add(item);
90 }
91 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
92 self.add(source);
93 }
94 fn add_screen(&mut self, screen: routes::ScreenReg) {
95 self.add(screen);
96 }
97 fn add_public_route(&mut self, route: routes::PublicRouteReg) {
98 self.add(route);
99 }
100 fn add_persister(&mut self, persister: persist::PersisterReg) {
101 self.add(persister);
102 }
103}
104
105const SESSION_COOKIE: &str = "laterite_session";
106
107#[derive(Clone)]
109pub(crate) struct AdminState {
110 auth: AuthService,
111 db: Db,
112 nav: Arc<Vec<NavLink>>,
113 settings: Arc<Vec<settings::SettingsItem>>,
114 permissions: Arc<Vec<Permission>>,
115 admin_path: Arc<str>,
119 secure_cookie: bool,
120 origin: Arc<str>,
122 timezone: Tz,
123 default_locale: Arc<str>,
127 catalogs: Arc<CatalogStore>,
130 app_name: String,
133 brand_cache: Arc<RwLock<Option<String>>>,
137 field_types: Arc<field::FieldRegistry>,
140 column_types: Arc<list::ColumnRegistry>,
143 assets: Arc<AssetRegistry>,
145 pickers: Arc<picker::PickerRegistry>,
148 overrides: Arc<dyn field::OverrideResolver>,
152}
153
154impl AdminState {
155 #[cfg(test)]
156 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
157 Self {
158 auth,
159 db,
160 nav: Arc::new(Vec::new()),
161 settings: Arc::new(Vec::new()),
162 permissions: Arc::new(builtin_permissions()),
163 admin_path: Arc::from("/admin"),
164 secure_cookie: false,
165 origin: Arc::from(""),
166 timezone: Tz::UTC,
167 default_locale: Arc::from("en"),
168 catalogs: Arc::new(CatalogStore::default()),
169 app_name: "Laterite".to_string(),
170 brand_cache: Arc::new(RwLock::new(None)),
171 field_types: Arc::new(field::builtin_registry()),
172 column_types: Arc::new(list::builtin_column_registry()),
173 assets: Arc::new(builtin_assets()),
174 pickers: Arc::new(picker::PickerRegistry::new()),
175 overrides: Arc::new(field::NoOverrides),
176 }
177 }
178
179 async fn brand(&self) -> String {
183 {
184 let cached = self.brand_cache.read().unwrap().clone();
185 if let Some(name) = cached {
186 return name;
187 }
188 }
189 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
190 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
191 _ => self.app_name.clone(),
192 };
193 *self.brand_cache.write().unwrap() = Some(resolved.clone());
194 resolved
195 }
196
197 fn invalidate_brand(&self) {
199 *self.brand_cache.write().unwrap() = None;
200 }
201}
202
203#[derive(Clone)]
209#[non_exhaustive]
210pub struct AdminConfig {
211 pub secure_cookie: bool,
214 pub timezone: String,
218 pub locale: String,
222 pub app_name: String,
226 pub path: String,
230 pub origin: String,
235}
236
237impl Default for AdminConfig {
238 fn default() -> Self {
239 Self {
240 secure_cookie: false,
241 timezone: "UTC".to_string(),
242 locale: "en".to_string(),
243 app_name: "Laterite".to_string(),
244 path: "/admin".to_string(),
245 origin: String::new(),
246 }
247 }
248}
249
250#[derive(Clone)]
251struct NavLink {
252 label: Text,
253 path: String,
254 icon: Option<&'static str>,
257}
258
259#[derive(Clone)]
264pub(crate) struct Shell {
265 pub(crate) base: String,
269 brand: String,
273 nav: Vec<NavView>,
274 full_name: String,
275 initial: String,
276 tz: Tz,
280 i18n: Translator,
284 sidebar: Vec<settings::CategoryView>,
288 pub(crate) csrf_token: String,
292 pub(crate) flash: Vec<FlashLine>,
296 pub(crate) assets: Vec<PageAsset>,
300}
301
302#[derive(Clone)]
304pub(crate) struct PageAsset {
305 pub(crate) url: String,
306 pub(crate) css: bool,
307}
308
309#[derive(Clone)]
312pub(crate) struct FlashLine {
313 pub(crate) level: session::FlashLevel,
314 pub(crate) text: String,
315}
316
317impl Shell {
318 #[allow(clippy::too_many_arguments)]
319 fn new(
320 base: &str,
321 brand: String,
322 nav: &[NavLink],
323 user: &AuthenticatedUser,
324 default_tz: Tz,
325 sidebar: Vec<settings::CategoryView>,
326 active_nav: Option<&str>,
327 csrf_token: String,
328 flash: Vec<session::Flash>,
329 i18n: Translator,
330 ) -> Self {
331 let full_name = user.user.full_name();
332 let initial = full_name
333 .chars()
334 .next()
335 .map(|c| c.to_uppercase().to_string())
336 .unwrap_or_else(|| "?".to_string());
337 let flash = flash
340 .into_iter()
341 .map(|f| FlashLine {
342 level: f.level,
343 text: i18n.t(&f.text),
344 })
345 .collect();
346 let nav = nav
347 .iter()
348 .map(|n| NavView {
349 label: i18n.t(&n.label),
350 path: n.path.clone(),
351 active: active_nav == Some(n.path.as_str()),
352 icon: n.icon.map(|name| icons::svg(Some(name))).unwrap_or(""),
353 })
354 .collect();
355 Shell {
356 base: base.to_string(),
357 brand,
358 nav,
359 full_name,
360 initial,
361 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
362 i18n,
363 sidebar,
364 csrf_token,
365 flash,
366 assets: Vec::new(),
367 }
368 }
369
370 pub(crate) fn t(&self, source: &str) -> String {
373 self.i18n.t(&Text::dynamic(source))
374 }
375
376 pub(crate) fn tt(&self, text: &Text) -> String {
379 self.i18n.t(text)
380 }
381
382 pub(crate) fn locale(&self) -> &str {
384 self.i18n.locale()
385 }
386
387 pub(crate) fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
391 let mut text = Text::dynamic(source);
392 for (name, value) in args {
393 text = text.arg(*name, *value);
394 }
395 self.i18n.t(&text)
396 }
397
398 pub(crate) fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
401 let mut text = Text::dynamic(source);
402 for (name, value) in args {
403 text = text.arg(*name, *value);
404 }
405 self.i18n.t(&text)
406 }
407
408 #[cfg(test)]
409 pub(crate) fn test() -> Self {
410 Shell {
411 base: "/admin".to_string(),
412 brand: "Laterite".to_string(),
413 nav: Vec::new(),
414 full_name: "Test Operator".to_string(),
415 initial: "T".to_string(),
416 tz: Tz::UTC,
417 i18n: Translator::new("en"),
418 sidebar: Vec::new(),
419 csrf_token: "test-csrf-token".to_string(),
420 flash: Vec::new(),
421 assets: Vec::new(),
422 }
423 }
424}
425
426fn settings_context(
433 visible: &[settings::SettingsItem],
434 admin_path: &str,
435 path: &str,
436) -> (bool, Option<String>) {
437 let settings_root = format!("{admin_path}/settings");
438 if path == settings_root {
439 (true, None)
440 } else if let Some(code) = path.strip_prefix(&format!("{settings_root}/")) {
441 (true, Some(code.to_string()))
442 } else {
443 let active = visible
447 .iter()
448 .filter_map(|i| {
449 i.link
450 .as_deref()
451 .map(|link| (format!("{admin_path}{link}"), &i.code))
452 })
453 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
454 .max_by_key(|(link, _)| link.len())
455 .map(|(_, code)| code.clone());
456 (active.is_some(), active)
457 }
458}
459
460fn active_nav_path(
468 nav: &[NavLink],
469 admin_path: &str,
470 in_settings_context: bool,
471 path: &str,
472) -> Option<String> {
473 if in_settings_context {
474 return Some(format!("{admin_path}/settings"));
475 }
476 nav.iter()
477 .filter(|n| {
478 path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
479 })
480 .max_by_key(|n| n.path.len())
481 .map(|n| n.path.clone())
482}
483
484fn resolve_nav_context(
489 nav: &[NavLink],
490 items: &[settings::SettingsItem],
491 admin_path: &str,
492 perms: &PermissionSet,
493 path: &str,
494 tr: &Translator,
495) -> (Vec<settings::CategoryView>, Option<String>) {
496 let visible = visible_settings(items, perms);
497 let (in_context, active) = settings_context(&visible, admin_path, path);
498 let sidebar = if in_context {
499 settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr)
500 } else {
501 Vec::new()
502 };
503 let active_nav = active_nav_path(nav, admin_path, in_context, path);
504 (sidebar, active_nav)
505}
506
507fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
511 preference
512 .and_then(|name| name.parse::<Tz>().ok())
513 .unwrap_or(default_tz)
514}
515
516fn zone_offered(name: &str) -> bool {
518 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
519}
520
521fn base_lang(tag: &str) -> String {
524 tag.split(['-', '_'])
525 .next()
526 .unwrap_or(tag)
527 .to_ascii_lowercase()
528}
529
530fn is_serveable(base: &str, serveable: &[String]) -> bool {
536 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
537}
538
539fn default_locale(configured: &str, serveable: &[String]) -> String {
542 let base = base_lang(configured);
543 if is_serveable(&base, serveable) {
544 base
545 } else {
546 "en".to_string()
547 }
548}
549
550fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
554 let mut out = vec!["en".to_string()];
555 out.extend(catalogs.locales());
556 out
557}
558
559fn locale_name(code: &str) -> String {
564 if code == "en" {
565 "English".to_string()
566 } else {
567 code.to_string()
568 }
569}
570
571fn parse_accept_language(header: &str) -> Vec<String> {
574 let mut items: Vec<(f32, String)> = header
575 .split(',')
576 .filter_map(|part| {
577 let mut bits = part.split(';');
578 let tag = bits.next()?.trim();
579 if tag.is_empty() || tag == "*" {
580 return None;
581 }
582 let q = bits
583 .find_map(|p| p.trim().strip_prefix("q="))
584 .and_then(|q| q.parse::<f32>().ok())
585 .unwrap_or(1.0);
586 Some((q, tag.to_string()))
587 })
588 .collect();
589 items.sort_by(|a, b| b.0.total_cmp(&a.0));
591 items.into_iter().map(|(_, tag)| tag).collect()
592}
593
594fn resolve_locale_chain(
600 preference: Option<&str>,
601 accept_language: Option<&str>,
602 default: &str,
603 serveable: &[String],
604) -> Vec<String> {
605 let mut chain: Vec<String> = Vec::new();
606 let consider = |tag: &str, chain: &mut Vec<String>| {
607 let base = base_lang(tag);
608 if is_serveable(&base, serveable) && !chain.contains(&base) {
609 chain.push(base);
610 }
611 };
612 if let Some(pref) = preference {
613 consider(pref, &mut chain);
614 }
615 if let Some(header) = accept_language {
616 for tag in parse_accept_language(header) {
617 consider(&tag, &mut chain);
618 }
619 }
620 consider(default, &mut chain);
621 consider("en", &mut chain);
624 chain
625}
626
627fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
631 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
632 let serveable = state.catalogs.locales();
633 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
634 Translator::with_chain(chain, state.catalogs.clone())
635}
636
637#[derive(Serialize)]
640pub struct Resource {
641 pub base_path: String,
647 pub nav_label: Text,
649 pub list: list::ListConfig,
650 pub form: Option<form::FormConfig>,
651 pub permission: Option<String>,
658}
659
660#[derive(Clone, Serialize)]
665pub struct Permission {
666 pub code: String,
667 pub label: Text,
669 pub group: Text,
670}
671
672fn builtin_permissions() -> Vec<Permission> {
675 vec![
676 Permission {
677 code: "backend.manage_users".to_string(),
678 label: "Manage backend users".into(),
679 group: "Backend".into(),
680 },
681 Permission {
682 code: "backend.manage_roles".to_string(),
683 label: "Manage roles".into(),
684 group: "Backend".into(),
685 },
686 Permission {
687 code: "backend.manage_branding".to_string(),
688 label: "Manage branding".into(),
689 group: "Backend".into(),
690 },
691 Permission {
692 code: plugins::MANAGE_PERMISSION.to_string(),
693 label: "Manage plugins".into(),
694 group: "Backend".into(),
695 },
696 Permission {
697 code: "backend.view_audit_log".to_string(),
698 label: "View the audit log".into(),
699 group: "Backend".into(),
700 },
701 ]
702}
703
704pub fn descriptor_sources() -> Vec<String> {
710 let mut out = Vec::new();
711 out.extend(laterite_core::collect_sources(&builtin_resources()));
712 out.extend(laterite_core::collect_sources(&builtin_settings()));
713 out.extend(laterite_core::collect_sources(&builtin_permissions()));
714 out.sort();
715 out.dedup();
716 out
717}
718
719pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
734 builtin_modules().iter().map(|m| m.migrations()).collect()
735}
736
737pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
741 vec![
742 Box::new(laterite_auth::AuthModule),
743 Box::new(settings::SettingsModule),
744 Box::new(plugins::PluginsModule),
745 ]
746}
747
748#[allow(clippy::too_many_arguments)]
756pub fn router(
757 auth: AuthService,
758 db: Db,
759 app_resources: Vec<Resource>,
760 app_settings: Vec<settings::SettingsItem>,
761 app_permissions: Vec<Permission>,
762 app_picker_sources: Vec<picker::PickerSourceReg>,
763 app_persisters: Vec<persist::PersisterReg>,
764 app_listeners: Vec<laterite_core::ModelListenerReg>,
765 app_screens: Vec<routes::ScreenReg>,
766 app_public: Vec<routes::PublicRouteReg>,
767 config: AdminConfig,
768 catalogs: Arc<CatalogStore>,
769) -> Router {
770 let admin_path = normalize_path(&config.path);
771
772 let mut resources = builtin_resources();
773 let mut app_resources = app_resources;
774 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
778 prefix_resource(&admin_path, resource);
779 }
780
781 let mut settings = builtin_settings();
782 settings.extend(app_settings);
783 let mut permissions = builtin_permissions();
784 permissions.extend(app_permissions);
785
786 let mut nav = vec![NavLink {
790 label: "Dashboard".into(),
791 path: admin_path.clone(),
792 icon: Some("layout-dashboard"),
793 }];
794 for resource in &app_resources {
795 nav.push(NavLink {
796 label: resource.nav_label.clone(),
797 path: resource.base_path.clone(),
798 icon: None,
799 });
800 }
801 for reg in &app_screens {
804 if let Some(label) = ®.nav_label {
805 nav.push(NavLink {
806 label: label.clone(),
807 path: format!("{admin_path}{}", reg.base_path),
808 icon: None,
809 });
810 }
811 }
812 nav.push(NavLink {
813 label: "Settings".into(),
814 path: format!("{admin_path}/settings"),
815 icon: Some("settings"),
816 });
817 resources.extend(app_resources);
818
819 let app_name = if config.app_name.trim().is_empty() {
820 "Laterite".to_string()
821 } else {
822 config.app_name.clone()
823 };
824
825 let mut pickers = picker::PickerRegistry::new();
828 for reg in app_picker_sources {
829 let name = reg.name.clone();
830 if !field::is_name(&name, true) {
831 panic!("invalid picker source name `{name}`");
832 }
833 if pickers.insert(name.clone(), reg).is_some() {
834 panic!("duplicate picker source `{name}`");
835 }
836 }
837
838 let pickers = Arc::new(pickers);
842 let mut field_types = field::builtin_registry();
843 field_types.insert(
844 "reference".to_string(),
845 Arc::new(field::RefPickerField::new(pickers.clone())),
846 );
847
848 let mut persisters = persist::PersisterRegistry::new();
851 for reg in app_persisters {
852 let name = reg.name.clone();
853 if !field::is_name(&name, true) {
854 panic!("invalid persister name `{name}`");
855 }
856 if persisters.insert(name.clone(), reg.persister).is_some() {
857 panic!("duplicate persister `{name}`");
858 }
859 }
860
861 let state = AdminState {
862 auth,
863 db,
864 nav: Arc::new(nav),
865 settings: Arc::new(settings),
866 permissions: Arc::new(permissions),
867 admin_path: Arc::from(admin_path.as_str()),
868 secure_cookie: config.secure_cookie,
869 origin: Arc::from(config.origin.trim_end_matches('/')),
870 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
871 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
872 catalogs,
873 app_name,
874 brand_cache: Arc::new(RwLock::new(None)),
875 field_types: Arc::new(field_types),
876 column_types: Arc::new(list::builtin_column_registry()),
877 assets: Arc::new(builtin_assets()),
878 pickers,
879 overrides: Arc::new(field::NoOverrides),
880 };
881
882 let mut protected = Router::new().route(&admin_path, get(dashboard));
883 for resource in &resources {
884 protected = protected.merge(mount_resource(
885 resource,
886 &state.field_types,
887 &persisters,
888 &app_listeners,
889 ));
890 }
891 for reg in &app_screens {
895 let base = format!("{admin_path}{}", reg.base_path);
896 let ctx = routes::RouteCtx::new(state.db.clone(), &base, &admin_path);
897 protected = protected.merge(guard_with_permission(
898 Router::new().nest_service(&base, reg.screen.mount(&ctx)),
899 ®.permission,
900 ));
901 }
902
903 protected = protected.merge(guard_with_permission(
906 Router::new()
907 .route(
908 &format!("{admin_path}/roles/new"),
909 get(roles::new_form).post(roles::create),
910 )
911 .route(
912 &format!("{admin_path}/roles/{{id}}/edit"),
913 get(roles::edit_form).post(roles::update),
914 ),
915 "backend.manage_roles",
916 ));
917 protected = protected.merge(guard_with_permission(
920 Router::new().route(
921 &format!("{admin_path}/users/{{id}}/edit"),
922 get(users::edit_form).post(users::update),
923 ),
924 "backend.manage_users",
925 ));
926 protected = protected.merge(guard_with_permission(
929 Router::new()
930 .route(&format!("{admin_path}/plugins"), get(plugins::index))
931 .route(
932 &format!("{admin_path}/plugins/toggle"),
933 post(plugins::toggle),
934 ),
935 plugins::MANAGE_PERMISSION,
936 ));
937 protected = protected
938 .route(&format!("{admin_path}/settings"), get(settings_index))
939 .route(
940 &format!("{admin_path}/settings/{{code}}"),
941 get(settings_edit).post(settings_update),
942 )
943 .route(
944 &format!("{admin_path}/preferences"),
945 get(preferences_form).post(preferences_update),
946 )
947 .route(
950 &format!("{admin_path}/pickers/{{source}}/search"),
951 any(picker::search),
952 )
953 .route(
954 &format!("{admin_path}/pickers/{{source}}/resolve"),
955 any(picker::resolve),
956 )
957 .route(&format!("{admin_path}/logout"), post(logout));
958
959 protected
960 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
961 .route(
965 &format!("{admin_path}/login"),
966 get(login_form).post(login_submit),
967 )
968 .route(
969 &format!("{admin_path}/setup"),
970 get(setup_form).post(setup_submit),
971 )
972 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
973 .merge(mount_public(&app_public, &state.db, &admin_path))
977 .fallback(not_found_fallback)
979 .layer(middleware::from_fn_with_state(
982 state.clone(),
983 enforce_origin,
984 ))
985 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
986 handle_panic,
987 ))
988 .with_state(state)
989}
990
991async fn not_found_fallback() -> AdminError {
993 AdminError::NotFound
994}
995
996fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
998 let cause = err
999 .downcast_ref::<&str>()
1000 .map(|s| s.to_string())
1001 .or_else(|| err.downcast_ref::<String>().cloned())
1002 .unwrap_or_else(|| "unknown panic".to_string());
1003 tracing::error!(panic = cause, "admin handler panicked");
1004 error::masked_500()
1005}
1006
1007pub fn normalize_path(path: &str) -> String {
1012 let trimmed = path.trim().trim_matches('/');
1013 if trimmed.is_empty() {
1014 "/admin".to_string()
1015 } else {
1016 format!("/{trimmed}")
1017 }
1018}
1019
1020fn mount_public(regs: &[routes::PublicRouteReg], db: &Db, admin_path: &str) -> Router<AdminState> {
1024 let mut router = Router::new();
1025 for reg in regs {
1026 let ctx = routes::RouteCtx::new(db.clone(), ®.path, admin_path);
1027 router = router.nest_service(®.path, reg.route.mount(&ctx));
1028 }
1029 router
1030}
1031
1032pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
1035 let old = resource.base_path.clone();
1036 resource.base_path = base.to_string();
1037 if let Some(edit_base) = &mut resource.list.edit_base {
1038 if edit_base == &old {
1039 *edit_base = base.to_string();
1040 }
1041 }
1042 if let Some(form) = &mut resource.form {
1043 if form.base_path == old {
1044 form.base_path = base.to_string();
1045 }
1046 }
1047}
1048
1049fn prefix_resource(admin_path: &str, resource: &mut Resource) {
1050 resource.base_path = format!("{admin_path}{}", resource.base_path);
1051 if let Some(edit_base) = &mut resource.list.edit_base {
1052 *edit_base = format!("{admin_path}{edit_base}");
1053 }
1054 if let Some(form) = &mut resource.form {
1055 form.base_path = format!("{admin_path}{}", form.base_path);
1056 }
1057}
1058
1059pub(crate) struct AdminAsset {
1061 pub mime: &'static str,
1062 pub cache: &'static str,
1063 pub bytes: &'static [u8],
1064}
1065
1066pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
1069
1070pub(crate) fn page_assets(keys: &[&str], base: &str, registry: &AssetRegistry) -> Vec<PageAsset> {
1077 let mut seen = std::collections::HashSet::new();
1078 let mut out = Vec::new();
1079 for &key in keys {
1080 if !seen.insert(key) {
1081 continue;
1082 }
1083 match registry.get(key) {
1084 Some(asset) => out.push(PageAsset {
1085 url: format!("{base}/assets/{key}"),
1086 css: asset.mime.contains("css"),
1087 }),
1088 None => debug_assert!(false, "asset key `{key}` is not registered"),
1089 }
1090 }
1091 out
1092}
1093
1094const ASSET_IMMUTABLE: &str = "public, max-age=31536000, immutable";
1095
1096pub(crate) fn builtin_assets() -> AssetRegistry {
1100 let font = |bytes: &'static [u8]| AdminAsset {
1101 mime: "font/woff2",
1102 cache: ASSET_IMMUTABLE,
1103 bytes,
1104 };
1105 HashMap::from([
1106 (
1107 "laterite.css",
1108 AdminAsset {
1109 mime: "text/css; charset=utf-8",
1110 cache: "no-cache",
1111 bytes: include_bytes!("../assets/laterite.css"),
1112 },
1113 ),
1114 (
1115 "laterite.js",
1116 AdminAsset {
1117 mime: "text/javascript; charset=utf-8",
1118 cache: "no-cache",
1119 bytes: include_bytes!("../assets/laterite.js"),
1120 },
1121 ),
1122 (
1123 "vendor/htmx.min.js",
1124 AdminAsset {
1125 mime: "text/javascript; charset=utf-8",
1126 cache: "no-cache",
1127 bytes: include_bytes!("../assets/vendor/htmx.min.js"),
1128 },
1129 ),
1130 (
1131 "fields/ref-picker.js",
1132 AdminAsset {
1133 mime: "text/javascript; charset=utf-8",
1134 cache: "no-cache",
1135 bytes: include_bytes!("../assets/fields/ref-picker.js"),
1136 },
1137 ),
1138 (
1139 "fields/ref-picker.css",
1140 AdminAsset {
1141 mime: "text/css; charset=utf-8",
1142 cache: "no-cache",
1143 bytes: include_bytes!("../assets/fields/ref-picker.css"),
1144 },
1145 ),
1146 (
1147 "mark.svg",
1148 AdminAsset {
1149 mime: "image/svg+xml",
1150 cache: ASSET_IMMUTABLE,
1151 bytes: include_bytes!("../assets/mark.svg"),
1152 },
1153 ),
1154 (
1155 "mark.png",
1156 AdminAsset {
1157 mime: "image/png",
1158 cache: ASSET_IMMUTABLE,
1159 bytes: include_bytes!("../assets/mark.png"),
1160 },
1161 ),
1162 (
1163 "fonts/space-grotesk-500.woff2",
1164 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1165 ),
1166 (
1167 "fonts/space-grotesk-600.woff2",
1168 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1169 ),
1170 (
1171 "fonts/space-grotesk-700.woff2",
1172 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1173 ),
1174 (
1175 "fonts/ibm-plex-sans-400.woff2",
1176 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1177 ),
1178 (
1179 "fonts/ibm-plex-sans-600.woff2",
1180 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1181 ),
1182 (
1183 "fonts/ibm-plex-mono-400.woff2",
1184 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1185 ),
1186 (
1187 "fonts/ibm-plex-mono-600.woff2",
1188 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1189 ),
1190 ])
1191}
1192
1193async fn serve_asset(State(state): State<AdminState>, Path(path): Path<String>) -> Response {
1195 match state.assets.get(path.as_str()) {
1196 Some(asset) => (
1197 [
1198 (header::CONTENT_TYPE, asset.mime),
1199 (header::CACHE_CONTROL, asset.cache),
1200 ],
1201 asset.bytes,
1202 )
1203 .into_response(),
1204 None => not_found(),
1205 }
1206}
1207
1208fn mount_resource(
1213 resource: &Resource,
1214 field_types: &field::FieldRegistry,
1215 persisters: &persist::PersisterRegistry,
1216 listeners: &[laterite_core::ModelListenerReg],
1217) -> Router<AdminState> {
1218 if resource.form.is_some() && resource.permission.is_none() {
1223 panic!(
1224 "admin resource `{}` has a create/edit form but no permission; a write \
1225 resource must declare a permission",
1226 resource.base_path
1227 );
1228 }
1229 let base = resource.base_path.clone();
1230 let list_cfg = resource.list.clone();
1231 let mut router = Router::new().route(
1232 &base,
1233 get(
1234 move |State(state): State<AdminState>,
1235 Extension(shell): Extension<Shell>,
1236 Query(params): Query<list::ListParams>| {
1237 let cfg = list_cfg.clone();
1238 async move { list::handle(&state, &cfg, params, shell).await }
1239 },
1240 ),
1241 );
1242
1243 if let Some(form_cfg) = resource.form.clone() {
1244 let prepared = Arc::new(
1247 form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
1248 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1249 );
1250 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1251 router = router.route(
1252 &format!("{base}/new"),
1253 get(
1254 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1255 let pf = new_pf.clone();
1256 async move { form::new_form(&state, &pf, shell) }
1257 },
1258 )
1259 .post(
1260 move |State(state): State<AdminState>,
1261 Extension(shell): Extension<Shell>,
1262 Extension(user): Extension<AuthenticatedUser>,
1263 Form(data): Form<HashMap<String, String>>| {
1264 let pf = create_pf.clone();
1265 async move { form::create(&state, &pf, data, shell, &user).await }
1266 },
1267 ),
1268 );
1269
1270 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1271 router = router.route(
1272 &format!("{base}/{{id}}/edit"),
1273 get(
1274 move |State(state): State<AdminState>,
1275 Extension(shell): Extension<Shell>,
1276 Path(id): Path<String>| {
1277 let pf = edit_pf.clone();
1278 async move { form::edit_form(&state, &pf, id, shell).await }
1279 },
1280 )
1281 .post(
1282 move |State(state): State<AdminState>,
1283 Extension(shell): Extension<Shell>,
1284 Extension(user): Extension<AuthenticatedUser>,
1285 Path(id): Path<String>,
1286 Form(data): Form<HashMap<String, String>>| {
1287 let pf = update_pf.clone();
1288 async move { form::update(&state, &pf, id, data, shell, &user).await }
1289 },
1290 ),
1291 );
1292 }
1293
1294 if let Some(permission) = &resource.permission {
1296 router = guard_with_permission(router, permission);
1297 }
1298 router
1299}
1300
1301fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
1306 let needed: Arc<str> = Arc::from(permission);
1307 router.route_layer(middleware::from_fn(
1308 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
1309 let needed = needed.clone();
1310 async move {
1311 if user.allows(&needed) {
1312 next.run(req).await
1313 } else {
1314 forbidden()
1315 }
1316 }
1317 },
1318 ))
1319}
1320
1321async fn require_auth(
1327 State(state): State<AdminState>,
1328 jar: CookieJar,
1329 request: Request,
1330 next: Next,
1331) -> Response {
1332 let login = format!("{}/login", state.admin_path);
1333 let token = match jar.get(SESSION_COOKIE) {
1334 Some(cookie) => cookie.value().to_string(),
1335 None => return Redirect::to(&login).into_response(),
1336 };
1337 let resolved = match state.auth.resolve_session(&token).await {
1338 Ok(resolved) => resolved,
1339 Err(_) => return Redirect::to(&login).into_response(),
1340 };
1341 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
1342
1343 let mut request = request;
1348 let safe = session::is_safe_method(request.method());
1349 if !safe {
1350 let (parts, body) = request.into_parts();
1351 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
1352 .await
1353 .unwrap_or_default();
1354 let submitted = session::submitted_token(&parts.headers, &bytes);
1355 if !session::token_matches(&handle.csrf_token(), submitted.as_deref()) {
1356 tracing::warn!(
1357 user_id = resolved.identity.user.id,
1358 "admin CSRF check failed"
1359 );
1360 return error::csrf_rejected();
1361 }
1362 request = Request::from_parts(parts, Body::from(bytes));
1363 }
1364
1365 let flash = if safe {
1368 handle.take_flash()
1369 } else {
1370 Vec::new()
1371 };
1372 let user = resolved.identity;
1373 let path = request.uri().path().to_string();
1374 let accept_language = request
1378 .headers()
1379 .get("accept-language")
1380 .and_then(|v| v.to_str().ok());
1381 let serveable = state.catalogs.locales();
1382 let chain = resolve_locale_chain(
1383 user.user.locale.as_deref(),
1384 accept_language,
1385 &state.default_locale,
1386 &serveable,
1387 );
1388 let i18n = Translator::with_chain(chain, state.catalogs.clone());
1389 let translator = i18n.clone();
1392 let (sidebar, active_nav) = resolve_nav_context(
1393 &state.nav,
1394 &state.settings,
1395 &state.admin_path,
1396 &user.permissions,
1397 &path,
1398 &i18n,
1399 );
1400 let brand = state.brand().await;
1401 let shell = Shell::new(
1402 &state.admin_path,
1403 brand,
1404 &state.nav,
1405 &user,
1406 state.timezone,
1407 sidebar,
1408 active_nav.as_deref(),
1409 handle.csrf_token(),
1410 flash,
1411 i18n,
1412 );
1413 request.extensions_mut().insert(user);
1414 request.extensions_mut().insert(shell);
1415 request.extensions_mut().insert(handle.clone());
1416 let mut response = next.run(request).await;
1417
1418 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
1422 response = error::localized_error(meta.kind, &translator);
1423 }
1424
1425 if let Some(blob) = handle.dirty_blob() {
1428 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
1429 tracing::error!(error = %e, "persisting admin session failed");
1430 }
1431 }
1432 response
1433}
1434
1435const MAX_FORM_BYTES: usize = 1024 * 1024;
1438
1439async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
1444 if !session::is_safe_method(request.method())
1445 && !session::origin_ok(request.headers(), &state.origin)
1446 {
1447 tracing::warn!(
1448 path = request.uri().path(),
1449 "admin CSRF origin check failed"
1450 );
1451 return error::csrf_rejected();
1452 }
1453 next.run(request).await
1454}
1455
1456async fn login_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
1457 match state.auth.has_any_operator().await {
1459 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
1460 Ok(true) => render(LoginTemplate {
1461 base: state.admin_path.to_string(),
1462 brand: state.brand().await,
1463 error: None,
1464 i18n: pre_auth_translator(&state, &headers),
1465 }),
1466 Err(_) => render_error(),
1467 }
1468}
1469
1470#[derive(Deserialize)]
1471struct LoginForm {
1472 username: String,
1473 password: String,
1474}
1475
1476async fn login_submit(
1477 State(state): State<AdminState>,
1478 jar: CookieJar,
1479 headers: HeaderMap,
1480 Form(form): Form<LoginForm>,
1481) -> Response {
1482 match state
1483 .auth
1484 .authenticate(&form.username, &form.password, &RequestContext::default())
1485 .await
1486 {
1487 Ok(session) => {
1488 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
1489 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
1490 }
1491 Err(_) => {
1492 let i18n = pre_auth_translator(&state, &headers);
1493 render(LoginTemplate {
1494 base: state.admin_path.to_string(),
1495 brand: state.brand().await,
1496 error: Some(i18n.t(&t!("Invalid username or password."))),
1497 i18n,
1498 })
1499 }
1500 }
1501}
1502
1503fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
1506 Cookie::build((SESSION_COOKIE, token))
1507 .path(admin_path.to_string())
1508 .http_only(true)
1509 .secure(secure)
1510 .same_site(SameSite::Lax)
1511 .build()
1512}
1513
1514#[derive(Deserialize)]
1515struct SetupForm {
1516 username: String,
1517 first_name: String,
1518 last_name: String,
1519 email: String,
1520 password: String,
1521 timezone: String,
1522}
1523
1524async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
1527 match state.auth.has_any_operator().await {
1528 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1529 Ok(false) => render(setup_view(
1530 &state.admin_path,
1531 state.brand().await,
1532 state.timezone,
1533 None,
1534 pre_auth_translator(&state, &headers),
1535 )),
1536 Err(_) => render_error(),
1537 }
1538}
1539
1540async fn setup_submit(
1541 State(state): State<AdminState>,
1542 jar: CookieJar,
1543 headers: HeaderMap,
1544 Form(form): Form<SetupForm>,
1545) -> Response {
1546 match state.auth.has_any_operator().await {
1548 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1549 Ok(false) => {}
1550 Err(_) => return render_error(),
1551 }
1552
1553 let username = form.username.trim();
1554 let email = form.email.trim();
1555 let first_name = form.first_name.trim();
1556 let last_name = form.last_name.trim();
1557 let tz = form.timezone.trim();
1558 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
1559 {
1560 return render(setup_view(
1561 &state.admin_path,
1562 state.brand().await,
1563 state.timezone,
1564 Some(t!(
1565 "Username, first name, email, and password are all required."
1566 )),
1567 pre_auth_translator(&state, &headers),
1568 ));
1569 }
1570 if tz.parse::<Tz>().is_err() {
1572 return render(setup_view(
1573 &state.admin_path,
1574 state.brand().await,
1575 state.timezone,
1576 Some(t!("That is not a recognised timezone.")),
1577 pre_auth_translator(&state, &headers),
1578 ));
1579 }
1580
1581 let new = NewOperator {
1582 username,
1583 email,
1584 first_name,
1585 last_name: (!last_name.is_empty()).then_some(last_name),
1586 password: &form.password,
1587 timezone: Some(tz),
1588 };
1589 if state.auth.create_superuser(new).await.is_err() {
1590 return render(setup_view(
1591 &state.admin_path,
1592 state.brand().await,
1593 state.timezone,
1594 Some(t!(
1595 "Could not create the account. The username or email may already be taken."
1596 )),
1597 pre_auth_translator(&state, &headers),
1598 ));
1599 }
1600
1601 match state
1603 .auth
1604 .authenticate(username, &form.password, &RequestContext::default())
1605 .await
1606 {
1607 Ok(session) => {
1608 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
1609 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
1610 }
1611 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1612 }
1613}
1614
1615fn setup_view(
1618 admin_path: &str,
1619 brand: String,
1620 default_tz: Tz,
1621 error: Option<Text>,
1622 i18n: Translator,
1623) -> SetupTemplate {
1624 let default_name = default_tz.name();
1625 let zones = TZ_VARIANTS
1626 .iter()
1627 .filter(|tz| zone_offered(tz.name()))
1628 .map(|tz| TzOption {
1629 name: tz.name().to_string(),
1630 selected: tz.name() == default_name,
1631 })
1632 .collect();
1633 SetupTemplate {
1634 base: admin_path.to_string(),
1635 brand,
1636 zones,
1637 error: error.map(|e| i18n.t(&e)),
1638 i18n,
1639 }
1640}
1641
1642async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
1643 if let Some(cookie) = jar.get(SESSION_COOKIE) {
1644 let _ = state.auth.logout(cookie.value()).await;
1645 }
1646 let removal = Cookie::build((SESSION_COOKIE, ""))
1647 .path(state.admin_path.to_string())
1648 .build();
1649 (
1650 jar.remove(removal),
1651 Redirect::to(&format!("{}/login", state.admin_path)),
1652 )
1653 .into_response()
1654}
1655
1656async fn dashboard(
1657 Extension(shell): Extension<Shell>,
1658 Extension(user): Extension<AuthenticatedUser>,
1659) -> Response {
1660 render(DashboardTemplate {
1661 username: user.user.username,
1662 shell,
1663 })
1664}
1665
1666fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
1669 match &item.permission {
1670 None => true,
1671 Some(p) => perms.allows(p),
1672 }
1673}
1674
1675fn visible_settings(
1679 items: &[settings::SettingsItem],
1680 perms: &PermissionSet,
1681) -> Vec<settings::SettingsItem> {
1682 items
1683 .iter()
1684 .filter(|item| operator_can_see(item, perms))
1685 .cloned()
1686 .collect()
1687}
1688
1689async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
1690 settings::index(shell)
1691}
1692
1693async fn settings_edit(
1694 State(state): State<AdminState>,
1695 Extension(shell): Extension<Shell>,
1696 Extension(user): Extension<AuthenticatedUser>,
1697 Path(code): Path<String>,
1698) -> Response {
1699 let items = visible_settings(&state.settings, &user.permissions);
1702 match items
1703 .iter()
1704 .find(|item| item.code == code && item.link.is_none())
1705 {
1706 Some(item) => settings::edit_form(&state, item, shell).await,
1707 None => not_found(),
1708 }
1709}
1710
1711async fn settings_update(
1712 State(state): State<AdminState>,
1713 Extension(shell): Extension<Shell>,
1714 Extension(user): Extension<AuthenticatedUser>,
1715 Extension(session): Extension<session::SessionHandle>,
1716 Path(code): Path<String>,
1717 Form(data): Form<HashMap<String, String>>,
1718) -> Response {
1719 let items = visible_settings(&state.settings, &user.permissions);
1720 match items
1721 .iter()
1722 .find(|item| item.code == code && item.link.is_none())
1723 {
1724 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
1725 None => not_found(),
1726 }
1727}
1728
1729async fn preferences_form(
1731 State(state): State<AdminState>,
1732 Extension(shell): Extension<Shell>,
1733 Extension(user): Extension<AuthenticatedUser>,
1734) -> Response {
1735 render(preferences_view(
1736 &shell,
1737 &user,
1738 state.timezone,
1739 &state.default_locale,
1740 &offered_locales(&state.catalogs),
1741 None,
1742 ))
1743}
1744
1745#[derive(Deserialize)]
1746struct PreferencesForm {
1747 timezone: String,
1748 #[serde(default)]
1750 locale: String,
1751}
1752
1753async fn preferences_update(
1754 State(state): State<AdminState>,
1755 Extension(shell): Extension<Shell>,
1756 Extension(user): Extension<AuthenticatedUser>,
1757 Extension(session): Extension<session::SessionHandle>,
1758 Form(form): Form<PreferencesForm>,
1759) -> Response {
1760 let offered = offered_locales(&state.catalogs);
1761 let tz = form.timezone.trim();
1763 let tz_stored = if tz.is_empty() {
1764 None
1765 } else if tz.parse::<Tz>().is_ok() {
1766 Some(tz)
1767 } else {
1768 return render(preferences_view(
1769 &shell,
1770 &user,
1771 state.timezone,
1772 &state.default_locale,
1773 &offered,
1774 Some(t!("That is not a recognised timezone.")),
1775 ));
1776 };
1777 let loc = form.locale.trim();
1778 let loc_stored = if loc.is_empty() {
1779 None
1780 } else if offered.iter().any(|l| l == loc) {
1781 Some(loc)
1782 } else {
1783 return render(preferences_view(
1784 &shell,
1785 &user,
1786 state.timezone,
1787 &state.default_locale,
1788 &offered,
1789 Some(t!("That is not a supported language.")),
1790 ));
1791 };
1792 if state
1793 .auth
1794 .set_user_timezone(user.user.id, tz_stored)
1795 .await
1796 .is_err()
1797 {
1798 return render_error();
1799 }
1800 match state.auth.set_user_locale(user.user.id, loc_stored).await {
1801 Ok(()) => {
1802 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
1803 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
1804 }
1805 Err(_) => render_error(),
1806 }
1807}
1808
1809fn preferences_view(
1813 shell: &Shell,
1814 user: &AuthenticatedUser,
1815 default_tz: Tz,
1816 default_locale: &str,
1817 offered: &[String],
1818 error: Option<Text>,
1819) -> PreferencesTemplate {
1820 let current = user.user.timezone.as_deref();
1821 let zones = TZ_VARIANTS
1822 .iter()
1823 .filter(|tz| zone_offered(tz.name()))
1824 .map(|tz| TzOption {
1825 name: tz.name().to_string(),
1826 selected: current == Some(tz.name()),
1827 })
1828 .collect();
1829 let current_locale = user.user.locale.as_deref();
1830 let locales = offered
1831 .iter()
1832 .map(|code| LocaleOption {
1833 name: locale_name(code),
1834 selected: current_locale == Some(code.as_str()),
1835 code: code.clone(),
1836 })
1837 .collect();
1838 PreferencesTemplate {
1839 shell: shell.clone(),
1840 zones,
1841 effective_tz: shell.tz.name().to_string(),
1842 default_tz: default_tz.name().to_string(),
1843 inherits: current.is_none(),
1844 locales,
1845 default_locale: locale_name(default_locale),
1846 inherits_locale: current_locale.is_none(),
1847 error: error.map(|e| shell.tt(&e)),
1848 }
1849}
1850
1851fn builtin_resources() -> Vec<Resource> {
1853 vec![
1854 Resource {
1855 base_path: "/users".to_string(),
1856 nav_label: "Backend Users".into(),
1857 list: backend_users_list_config(),
1858 form: None,
1859 permission: Some("backend.manage_users".to_string()),
1860 },
1861 Resource {
1862 base_path: "/roles".to_string(),
1863 nav_label: "Roles".into(),
1864 list: roles_list_config(),
1865 form: None,
1868 permission: Some("backend.manage_roles".to_string()),
1869 },
1870 Resource {
1871 base_path: "/audit-log".to_string(),
1872 nav_label: "Audit Log".into(),
1873 list: audit_log_list_config(),
1874 form: None,
1876 permission: Some("backend.view_audit_log".to_string()),
1877 },
1878 ]
1879}
1880
1881fn builtin_settings() -> Vec<settings::SettingsItem> {
1885 vec![
1886 settings::SettingsItem {
1887 code: "backend.administrators".to_string(),
1888 label: "Administrators".into(),
1889 description: "Manage backend administrator accounts.".into(),
1890 category: "Users".into(),
1891 order: 10,
1892 icon: Some("users".to_string()),
1893 permission: Some("backend.manage_users".to_string()),
1894 link: Some("/users".to_string()),
1895 fields: Vec::new(),
1896 },
1897 settings::SettingsItem {
1898 code: "backend.roles".to_string(),
1899 label: "Roles".into(),
1900 description: "Manage roles and their permissions.".into(),
1901 category: "Users".into(),
1902 order: 20,
1903 icon: Some("shield".to_string()),
1904 permission: Some("backend.manage_roles".to_string()),
1905 link: Some("/roles".to_string()),
1906 fields: Vec::new(),
1907 },
1908 settings::brand::settings_item(),
1909 settings::SettingsItem {
1910 code: "backend.plugins".to_string(),
1911 label: "Plugins".into(),
1912 description: "Enable or disable installed plugins.".into(),
1913 category: "System".into(),
1914 order: 10,
1915 icon: Some("plug".to_string()),
1916 permission: Some(plugins::MANAGE_PERMISSION.to_string()),
1917 link: Some("/plugins".to_string()),
1918 fields: Vec::new(),
1919 },
1920 settings::SettingsItem {
1921 code: "backend.audit_log".to_string(),
1922 label: "Audit Log".into(),
1923 description: "Review the record of administrative changes.".into(),
1924 category: "System".into(),
1925 order: 20,
1926 icon: Some("history".to_string()),
1927 permission: Some("backend.view_audit_log".to_string()),
1928 link: Some("/audit-log".to_string()),
1929 fields: Vec::new(),
1930 },
1931 ]
1932}
1933
1934fn backend_users_list_config() -> list::ListConfig {
1935 list::ListConfig {
1936 entity: "backend_users".to_string(),
1937 title: "Backend Users".into(),
1938 columns: vec![
1939 list::ListColumn::new("username", "Username"),
1940 list::ListColumn::new("email", "Email"),
1941 list::ListColumn::new("first_name", "First name"),
1942 list::ListColumn::new("last_name", "Last name"),
1943 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
1944 list::ListColumn::new("is_active", "Active").yes_no(),
1945 list::ListColumn::new("created_at", "Created").datetime(),
1946 ],
1947 order_by: "created_at".to_string(),
1948 order_dir: list::SortDir::Desc,
1949 per_page: 25,
1950 id_field: "id".to_string(),
1951 edit_base: Some("/users".to_string()),
1954 creatable: false,
1955 }
1956}
1957
1958fn roles_list_config() -> list::ListConfig {
1959 list::ListConfig {
1960 entity: "backend_roles".to_string(),
1961 title: "Roles".into(),
1962 columns: vec![
1963 list::ListColumn::new("code", "Code"),
1964 list::ListColumn::new("name", "Name"),
1965 list::ListColumn::new("created_at", "Created").datetime(),
1966 ],
1967 order_by: "created_at".to_string(),
1968 order_dir: list::SortDir::Desc,
1969 per_page: 25,
1970 id_field: "id".to_string(),
1971 edit_base: Some("/roles".to_string()),
1972 creatable: true,
1973 }
1974}
1975
1976fn audit_log_list_config() -> list::ListConfig {
1979 list::ListConfig {
1980 entity: "backend_audit_log".to_string(),
1981 title: "Audit Log".into(),
1982 columns: vec![
1983 list::ListColumn::new("created_at", "When").datetime(),
1984 list::ListColumn::new("actor_username", "Operator"),
1985 list::ListColumn::new("action", "Action"),
1986 list::ListColumn::new("target_type", "Target"),
1987 list::ListColumn::new("target_id", "Target ID"),
1988 ],
1989 order_by: "created_at".to_string(),
1990 order_dir: list::SortDir::Desc,
1991 per_page: 50,
1992 id_field: "id".to_string(),
1993 edit_base: None,
1994 creatable: false,
1995 }
1996}
1997
1998#[derive(Template)]
1999#[template(path = "login.html")]
2000struct LoginTemplate {
2001 base: String,
2003 brand: String,
2004 error: Option<String>,
2005 i18n: Translator,
2008}
2009
2010impl LoginTemplate {
2011 fn t(&self, source: &str) -> String {
2012 self.i18n.t(&Text::dynamic(source))
2013 }
2014 fn locale(&self) -> &str {
2015 self.i18n.locale()
2016 }
2017}
2018
2019#[derive(Template)]
2020#[template(path = "dashboard.html")]
2021struct DashboardTemplate {
2022 shell: Shell,
2023 username: String,
2024}
2025
2026#[derive(Template)]
2027#[template(path = "setup.html")]
2028struct SetupTemplate {
2029 base: String,
2031 brand: String,
2032 zones: Vec<TzOption>,
2033 error: Option<String>,
2034 i18n: Translator,
2036}
2037
2038impl SetupTemplate {
2039 fn t(&self, source: &str) -> String {
2040 self.i18n.t(&Text::dynamic(source))
2041 }
2042 fn locale(&self) -> &str {
2043 self.i18n.locale()
2044 }
2045}
2046
2047#[derive(Template)]
2048#[template(path = "preferences.html")]
2049struct PreferencesTemplate {
2050 shell: Shell,
2051 zones: Vec<TzOption>,
2052 effective_tz: String,
2054 default_tz: String,
2056 inherits: bool,
2058 locales: Vec<LocaleOption>,
2059 default_locale: String,
2061 inherits_locale: bool,
2063 error: Option<String>,
2064}
2065
2066struct TzOption {
2067 name: String,
2068 selected: bool,
2069}
2070
2071struct LocaleOption {
2072 code: String,
2074 name: String,
2076 selected: bool,
2077}
2078
2079#[derive(Clone)]
2080struct NavView {
2081 label: String,
2082 path: String,
2083 active: bool,
2084 icon: &'static str,
2087}
2088
2089pub(crate) fn render<T: Template>(template: T) -> Response {
2091 match template.render() {
2092 Ok(html) => Html(html).into_response(),
2093 Err(e) => AdminError::from(e).into_response(),
2095 }
2096}
2097
2098pub(crate) fn render_error() -> Response {
2099 error::masked_500()
2100}
2101
2102pub(crate) fn not_found() -> Response {
2103 AdminError::NotFound.into_response()
2104}
2105
2106pub(crate) fn forbidden() -> Response {
2107 AdminError::Forbidden.into_response()
2108}
2109
2110#[cfg(test)]
2111mod tests {
2112 use super::*;
2113
2114 fn write_form() -> form::FormConfig {
2115 form::FormConfig {
2116 entity: "widgets".to_string(),
2117 title: "Widgets".into(),
2118 base_path: "/widgets".to_string(),
2119 id_field: "id".to_string(),
2120 fields: vec![],
2121 persist: None,
2122 timestamps: false,
2123 }
2124 }
2125
2126 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
2127 Resource {
2128 base_path: "/widgets".to_string(),
2129 nav_label: "Widgets".into(),
2130 list: backend_users_list_config(),
2131 form,
2132 permission: permission.map(str::to_string),
2133 }
2134 }
2135
2136 #[test]
2137 #[should_panic(expected = "must declare a permission")]
2138 fn write_resource_without_permission_aborts_boot() {
2139 let resource = resource_with(Some(write_form()), None);
2142 let _ = mount_resource(
2143 &resource,
2144 &field::builtin_registry(),
2145 &persist::PersisterRegistry::new(),
2146 &[],
2147 );
2148 }
2149
2150 #[test]
2151 fn read_only_resource_may_omit_permission() {
2152 let resource = resource_with(None, None);
2154 let _ = mount_resource(
2155 &resource,
2156 &field::builtin_registry(),
2157 &persist::PersisterRegistry::new(),
2158 &[],
2159 );
2160 }
2161
2162 #[test]
2163 fn shell_localizes_through_the_translator_and_falls_back() {
2164 let shell = Shell::test();
2166 assert_eq!(shell.t("Save"), "Save");
2167 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
2168 }
2169
2170 #[test]
2171 fn shell_tf_interpolates_integer_arguments() {
2172 let shell = Shell::test();
2173 assert_eq!(
2174 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
2175 "Page 2 of 5"
2176 );
2177 }
2178
2179 #[test]
2180 fn display_tz_prefers_a_valid_operator_preference() {
2181 assert_eq!(
2182 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
2183 Tz::Asia__Kolkata
2184 );
2185 }
2186
2187 #[test]
2188 fn display_tz_falls_back_when_unset_or_invalid() {
2189 let default = Tz::Europe__London;
2190 assert_eq!(resolve_display_tz(None, default), default);
2192 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
2194 assert_eq!(resolve_display_tz(Some(""), default), default);
2195 }
2196
2197 #[test]
2198 fn accept_language_orders_by_weight_and_drops_wildcard() {
2199 assert_eq!(
2200 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
2201 vec!["en-US", "en", "kn"]
2202 );
2203 assert_eq!(
2205 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
2206 vec!["kn", "en"]
2207 );
2208 }
2209
2210 #[test]
2211 fn locale_chain_prefers_operator_then_header_then_default() {
2212 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
2214 assert_eq!(
2217 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
2218 vec!["kn", "ta", "hi", "en"]
2219 );
2220 assert_eq!(
2222 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
2223 vec!["kn", "en", "ta"]
2224 );
2225 assert_eq!(
2227 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
2228 vec!["kn", "en"]
2229 );
2230 }
2231
2232 #[test]
2233 fn locale_chain_skips_unserveable_and_defaults_to_en() {
2234 let loaded = ["hi".to_string()];
2236 assert_eq!(
2239 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
2240 vec!["en"]
2241 );
2242 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
2244 assert_eq!(
2246 resolve_locale_chain(Some("hi"), None, "en", &loaded),
2247 vec!["hi", "en"]
2248 );
2249 assert_eq!(
2251 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
2252 vec![laterite_core::PSEUDO_LOCALE, "en"]
2253 );
2254 }
2255
2256 #[test]
2257 fn page_assets_dedupe_preserve_order_and_classify() {
2258 let mut reg = AssetRegistry::new();
2259 reg.insert(
2260 "a.js",
2261 AdminAsset {
2262 mime: "text/javascript; charset=utf-8",
2263 cache: "no-cache",
2264 bytes: b"",
2265 },
2266 );
2267 reg.insert(
2268 "b.css",
2269 AdminAsset {
2270 mime: "text/css; charset=utf-8",
2271 cache: "no-cache",
2272 bytes: b"",
2273 },
2274 );
2275 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®);
2278 assert_eq!(assets.len(), 2);
2279 assert_eq!(assets[0].url, "/admin/assets/a.js");
2280 assert!(!assets[0].css);
2281 assert_eq!(assets[1].url, "/admin/assets/b.css");
2282 assert!(assets[1].css);
2283 }
2284
2285 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
2289 laterite_core::testing::connect_test(&[]).await
2290 }
2291
2292 #[tokio::test]
2293 async fn builtin_migrations_create_the_admin_tables() {
2294 let (db, _guard) = empty_db().await;
2295 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
2296 .await
2297 .unwrap();
2298 for table in ["backend_users", "settings"] {
2302 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
2303 .fetch_optional(&db.pool)
2304 .await;
2305 assert!(
2306 probe.is_ok(),
2307 "{table} should exist after builtin_migrations"
2308 );
2309 }
2310 }
2311
2312 #[tokio::test]
2313 async fn brand_setting_overrides_config_and_blank_falls_back() {
2314 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
2315 let state = AdminState {
2316 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2317 db: db.clone(),
2318 nav: Arc::new(Vec::new()),
2319 settings: Arc::new(Vec::new()),
2320 permissions: Arc::new(builtin_permissions()),
2321 admin_path: Arc::from("/admin"),
2322 secure_cookie: false,
2323 origin: Arc::from(""),
2324 timezone: Tz::UTC,
2325 default_locale: Arc::from("en"),
2326 catalogs: Arc::new(CatalogStore::default()),
2327 app_name: "Configured Name".to_string(),
2328 brand_cache: Arc::new(RwLock::new(None)),
2329 field_types: Arc::new(field::builtin_registry()),
2330 column_types: Arc::new(list::builtin_column_registry()),
2331 assets: Arc::new(builtin_assets()),
2332 pickers: Arc::new(picker::PickerRegistry::new()),
2333 overrides: Arc::new(field::NoOverrides),
2334 };
2335
2336 assert_eq!(state.brand().await, "Configured Name");
2338
2339 settings::store::save(
2342 &db,
2343 &settings::BrandSetting {
2344 app_name: "Acme Corp".to_string(),
2345 },
2346 )
2347 .await
2348 .unwrap();
2349 state.invalidate_brand();
2350 assert_eq!(state.brand().await, "Acme Corp");
2351
2352 settings::store::save(
2354 &db,
2355 &settings::BrandSetting {
2356 app_name: " ".to_string(),
2357 },
2358 )
2359 .await
2360 .unwrap();
2361 state.invalidate_brand();
2362 assert_eq!(state.brand().await, "Configured Name");
2363 }
2364
2365 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
2366 settings::SettingsItem {
2367 code: code.to_string(),
2368 label: code.into(),
2369 description: String::new().into(),
2370 category: "General".into(),
2371 order: 1,
2372 icon: None,
2373 permission: permission.map(str::to_string),
2374 link: None,
2375 fields: Vec::new(),
2376 }
2377 }
2378
2379 #[test]
2380 fn settings_visibility_respects_permissions() {
2381 let items = vec![
2382 settings_item("public", None),
2383 settings_item("gated", Some("backend.manage_users")),
2384 ];
2385
2386 let none = PermissionSet::new(false, Vec::<String>::new());
2388 let codes: Vec<String> = visible_settings(&items, &none)
2389 .into_iter()
2390 .map(|i| i.code)
2391 .collect();
2392 assert_eq!(codes, ["public"]);
2393
2394 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
2396 assert_eq!(visible_settings(&items, &granted).len(), 2);
2397
2398 let superuser = PermissionSet::new(true, Vec::<String>::new());
2400 assert_eq!(visible_settings(&items, &superuser).len(), 2);
2401 }
2402
2403 #[test]
2404 fn context_sidebar_follows_settings_links() {
2405 let items = builtin_settings();
2406 let superuser = PermissionSet::new(true, Vec::<String>::new());
2407 let tr = Translator::new("en");
2408 let sidebar =
2409 |path: &str| resolve_nav_context(&[], &items, "/admin", &superuser, path, &tr).0;
2410 let active_path = |path: &str| -> Option<String> {
2411 sidebar(path)
2412 .into_iter()
2413 .flat_map(|g| g.items)
2414 .find(|i| i.active)
2415 .map(|i| i.path)
2416 };
2417
2418 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
2420 assert_eq!(
2421 active_path("/admin/roles/42/edit").as_deref(),
2422 Some("/admin/roles")
2423 );
2424 assert!(!sidebar("/admin/settings").is_empty());
2426 assert_eq!(active_path("/admin/settings"), None);
2427 assert_eq!(
2429 active_path("/admin/settings/backend.roles").as_deref(),
2430 Some("/admin/roles")
2431 );
2432 assert!(sidebar("/admin").is_empty());
2434 }
2435
2436 #[test]
2437 fn active_nav_lights_the_right_tab() {
2438 let nav = vec![
2439 NavLink {
2440 label: "Dashboard".into(),
2441 path: "/admin".to_string(),
2442 icon: Some("layout-dashboard"),
2443 },
2444 NavLink {
2445 label: "Pages".into(),
2446 path: "/admin/pages".to_string(),
2447 icon: None,
2448 },
2449 NavLink {
2450 label: "Settings".into(),
2451 path: "/admin/settings".to_string(),
2452 icon: Some("settings"),
2453 },
2454 ];
2455
2456 assert_eq!(
2458 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
2459 Some("/admin")
2460 );
2461 assert_eq!(
2463 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
2464 Some("/admin/pages")
2465 );
2466 assert_eq!(
2468 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
2469 None
2470 );
2471 assert_eq!(
2474 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
2475 None
2476 );
2477 assert_eq!(
2480 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
2481 Some("/admin/settings")
2482 );
2483 assert_eq!(
2484 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
2485 Some("/admin/settings")
2486 );
2487 }
2488}