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 session;
30pub mod settings;
31mod sql;
32mod users;
33
34pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
35pub use error::AdminError;
36pub use session::{FlashLevel, SessionHandle};
37
38use std::collections::HashMap;
39use std::sync::{Arc, RwLock};
40
41use askama::Template;
42use axum::body::Body;
43use axum::extract::{Path, Query, Request, State};
44use axum::http::{header, HeaderMap};
45use axum::middleware::{self, Next};
46use axum::response::{Html, IntoResponse, Redirect, Response};
47use axum::routing::{any, get, post};
48use axum::{Extension, Form, Router};
49use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
50use chrono_tz::{Tz, TZ_VARIANTS};
51use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
52use laterite_core::{t, CatalogStore, Db, Text, Translator};
53use serde::{Deserialize, Serialize};
54
55pub trait AdminRegistry {
61 fn add_resource(&mut self, resource: Resource);
63 fn add_permission(&mut self, permission: Permission);
65 fn add_settings(&mut self, item: settings::SettingsItem);
67 fn add_picker_source(&mut self, source: picker::PickerSourceReg);
69 fn add_persister(&mut self, persister: persist::PersisterReg);
71}
72
73impl AdminRegistry for laterite_core::Registry {
74 fn add_resource(&mut self, resource: Resource) {
75 self.add(resource);
76 }
77 fn add_permission(&mut self, permission: Permission) {
78 self.add(permission);
79 }
80 fn add_settings(&mut self, item: settings::SettingsItem) {
81 self.add(item);
82 }
83 fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
84 self.add(source);
85 }
86 fn add_persister(&mut self, persister: persist::PersisterReg) {
87 self.add(persister);
88 }
89}
90
91const SESSION_COOKIE: &str = "laterite_session";
92
93#[derive(Clone)]
95pub(crate) struct AdminState {
96 auth: AuthService,
97 db: Db,
98 nav: Arc<Vec<NavLink>>,
99 settings: Arc<Vec<settings::SettingsItem>>,
100 permissions: Arc<Vec<Permission>>,
101 admin_path: Arc<str>,
105 secure_cookie: bool,
106 origin: Arc<str>,
108 timezone: Tz,
109 default_locale: Arc<str>,
113 catalogs: Arc<CatalogStore>,
116 app_name: String,
119 brand_cache: Arc<RwLock<Option<String>>>,
123 field_types: Arc<field::FieldRegistry>,
126 column_types: Arc<list::ColumnRegistry>,
129 assets: Arc<AssetRegistry>,
131 pickers: Arc<picker::PickerRegistry>,
134 overrides: Arc<dyn field::OverrideResolver>,
138}
139
140impl AdminState {
141 #[cfg(test)]
142 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
143 Self {
144 auth,
145 db,
146 nav: Arc::new(Vec::new()),
147 settings: Arc::new(Vec::new()),
148 permissions: Arc::new(builtin_permissions()),
149 admin_path: Arc::from("/admin"),
150 secure_cookie: false,
151 origin: Arc::from(""),
152 timezone: Tz::UTC,
153 default_locale: Arc::from("en"),
154 catalogs: Arc::new(CatalogStore::default()),
155 app_name: "Laterite".to_string(),
156 brand_cache: Arc::new(RwLock::new(None)),
157 field_types: Arc::new(field::builtin_registry()),
158 column_types: Arc::new(list::builtin_column_registry()),
159 assets: Arc::new(builtin_assets()),
160 pickers: Arc::new(picker::PickerRegistry::new()),
161 overrides: Arc::new(field::NoOverrides),
162 }
163 }
164
165 async fn brand(&self) -> String {
169 {
170 let cached = self.brand_cache.read().unwrap().clone();
171 if let Some(name) = cached {
172 return name;
173 }
174 }
175 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
176 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
177 _ => self.app_name.clone(),
178 };
179 *self.brand_cache.write().unwrap() = Some(resolved.clone());
180 resolved
181 }
182
183 fn invalidate_brand(&self) {
185 *self.brand_cache.write().unwrap() = None;
186 }
187}
188
189#[derive(Clone)]
195#[non_exhaustive]
196pub struct AdminConfig {
197 pub secure_cookie: bool,
200 pub timezone: String,
204 pub locale: String,
208 pub app_name: String,
212 pub path: String,
216 pub origin: String,
221}
222
223impl Default for AdminConfig {
224 fn default() -> Self {
225 Self {
226 secure_cookie: false,
227 timezone: "UTC".to_string(),
228 locale: "en".to_string(),
229 app_name: "Laterite".to_string(),
230 path: "/admin".to_string(),
231 origin: String::new(),
232 }
233 }
234}
235
236#[derive(Clone)]
237struct NavLink {
238 label: Text,
239 path: String,
240 icon: Option<&'static str>,
243}
244
245#[derive(Clone)]
250pub(crate) struct Shell {
251 pub(crate) base: String,
255 brand: String,
259 nav: Vec<NavView>,
260 full_name: String,
261 initial: String,
262 tz: Tz,
266 i18n: Translator,
270 sidebar: Vec<settings::CategoryView>,
274 pub(crate) csrf_token: String,
278 pub(crate) flash: Vec<FlashLine>,
282 pub(crate) assets: Vec<PageAsset>,
286}
287
288#[derive(Clone)]
290pub(crate) struct PageAsset {
291 pub(crate) url: String,
292 pub(crate) css: bool,
293}
294
295#[derive(Clone)]
298pub(crate) struct FlashLine {
299 pub(crate) level: session::FlashLevel,
300 pub(crate) text: String,
301}
302
303impl Shell {
304 #[allow(clippy::too_many_arguments)]
305 fn new(
306 base: &str,
307 brand: String,
308 nav: &[NavLink],
309 user: &AuthenticatedUser,
310 default_tz: Tz,
311 sidebar: Vec<settings::CategoryView>,
312 active_nav: Option<&str>,
313 csrf_token: String,
314 flash: Vec<session::Flash>,
315 i18n: Translator,
316 ) -> Self {
317 let full_name = user.user.full_name();
318 let initial = full_name
319 .chars()
320 .next()
321 .map(|c| c.to_uppercase().to_string())
322 .unwrap_or_else(|| "?".to_string());
323 let flash = flash
326 .into_iter()
327 .map(|f| FlashLine {
328 level: f.level,
329 text: i18n.t(&f.text),
330 })
331 .collect();
332 let nav = nav
333 .iter()
334 .map(|n| NavView {
335 label: i18n.t(&n.label),
336 path: n.path.clone(),
337 active: active_nav == Some(n.path.as_str()),
338 icon: n.icon.map(|name| icons::svg(Some(name))).unwrap_or(""),
339 })
340 .collect();
341 Shell {
342 base: base.to_string(),
343 brand,
344 nav,
345 full_name,
346 initial,
347 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
348 i18n,
349 sidebar,
350 csrf_token,
351 flash,
352 assets: Vec::new(),
353 }
354 }
355
356 pub(crate) fn t(&self, source: &str) -> String {
359 self.i18n.t(&Text::dynamic(source))
360 }
361
362 pub(crate) fn tt(&self, text: &Text) -> String {
365 self.i18n.t(text)
366 }
367
368 pub(crate) fn locale(&self) -> &str {
370 self.i18n.locale()
371 }
372
373 pub(crate) fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
377 let mut text = Text::dynamic(source);
378 for (name, value) in args {
379 text = text.arg(*name, *value);
380 }
381 self.i18n.t(&text)
382 }
383
384 pub(crate) fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
387 let mut text = Text::dynamic(source);
388 for (name, value) in args {
389 text = text.arg(*name, *value);
390 }
391 self.i18n.t(&text)
392 }
393
394 #[cfg(test)]
395 pub(crate) fn test() -> Self {
396 Shell {
397 base: "/admin".to_string(),
398 brand: "Laterite".to_string(),
399 nav: Vec::new(),
400 full_name: "Test Operator".to_string(),
401 initial: "T".to_string(),
402 tz: Tz::UTC,
403 i18n: Translator::new("en"),
404 sidebar: Vec::new(),
405 csrf_token: "test-csrf-token".to_string(),
406 flash: Vec::new(),
407 assets: Vec::new(),
408 }
409 }
410}
411
412fn settings_context(
419 visible: &[settings::SettingsItem],
420 admin_path: &str,
421 path: &str,
422) -> (bool, Option<String>) {
423 let settings_root = format!("{admin_path}/settings");
424 if path == settings_root {
425 (true, None)
426 } else if let Some(code) = path.strip_prefix(&format!("{settings_root}/")) {
427 (true, Some(code.to_string()))
428 } else {
429 let active = visible
433 .iter()
434 .filter_map(|i| {
435 i.link
436 .as_deref()
437 .map(|link| (format!("{admin_path}{link}"), &i.code))
438 })
439 .filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
440 .max_by_key(|(link, _)| link.len())
441 .map(|(_, code)| code.clone());
442 (active.is_some(), active)
443 }
444}
445
446fn active_nav_path(
454 nav: &[NavLink],
455 admin_path: &str,
456 in_settings_context: bool,
457 path: &str,
458) -> Option<String> {
459 if in_settings_context {
460 return Some(format!("{admin_path}/settings"));
461 }
462 nav.iter()
463 .filter(|n| {
464 path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
465 })
466 .max_by_key(|n| n.path.len())
467 .map(|n| n.path.clone())
468}
469
470fn resolve_nav_context(
475 nav: &[NavLink],
476 items: &[settings::SettingsItem],
477 admin_path: &str,
478 perms: &PermissionSet,
479 path: &str,
480 tr: &Translator,
481) -> (Vec<settings::CategoryView>, Option<String>) {
482 let visible = visible_settings(items, perms);
483 let (in_context, active) = settings_context(&visible, admin_path, path);
484 let sidebar = if in_context {
485 settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr)
486 } else {
487 Vec::new()
488 };
489 let active_nav = active_nav_path(nav, admin_path, in_context, path);
490 (sidebar, active_nav)
491}
492
493fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
497 preference
498 .and_then(|name| name.parse::<Tz>().ok())
499 .unwrap_or(default_tz)
500}
501
502fn zone_offered(name: &str) -> bool {
504 !matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
505}
506
507fn base_lang(tag: &str) -> String {
510 tag.split(['-', '_'])
511 .next()
512 .unwrap_or(tag)
513 .to_ascii_lowercase()
514}
515
516fn is_serveable(base: &str, serveable: &[String]) -> bool {
522 base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
523}
524
525fn default_locale(configured: &str, serveable: &[String]) -> String {
528 let base = base_lang(configured);
529 if is_serveable(&base, serveable) {
530 base
531 } else {
532 "en".to_string()
533 }
534}
535
536fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
540 let mut out = vec!["en".to_string()];
541 out.extend(catalogs.locales());
542 out
543}
544
545fn locale_name(code: &str) -> String {
550 if code == "en" {
551 "English".to_string()
552 } else {
553 code.to_string()
554 }
555}
556
557fn parse_accept_language(header: &str) -> Vec<String> {
560 let mut items: Vec<(f32, String)> = header
561 .split(',')
562 .filter_map(|part| {
563 let mut bits = part.split(';');
564 let tag = bits.next()?.trim();
565 if tag.is_empty() || tag == "*" {
566 return None;
567 }
568 let q = bits
569 .find_map(|p| p.trim().strip_prefix("q="))
570 .and_then(|q| q.parse::<f32>().ok())
571 .unwrap_or(1.0);
572 Some((q, tag.to_string()))
573 })
574 .collect();
575 items.sort_by(|a, b| b.0.total_cmp(&a.0));
577 items.into_iter().map(|(_, tag)| tag).collect()
578}
579
580fn resolve_locale_chain(
586 preference: Option<&str>,
587 accept_language: Option<&str>,
588 default: &str,
589 serveable: &[String],
590) -> Vec<String> {
591 let mut chain: Vec<String> = Vec::new();
592 let consider = |tag: &str, chain: &mut Vec<String>| {
593 let base = base_lang(tag);
594 if is_serveable(&base, serveable) && !chain.contains(&base) {
595 chain.push(base);
596 }
597 };
598 if let Some(pref) = preference {
599 consider(pref, &mut chain);
600 }
601 if let Some(header) = accept_language {
602 for tag in parse_accept_language(header) {
603 consider(&tag, &mut chain);
604 }
605 }
606 consider(default, &mut chain);
607 consider("en", &mut chain);
610 chain
611}
612
613fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
617 let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
618 let serveable = state.catalogs.locales();
619 let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
620 Translator::with_chain(chain, state.catalogs.clone())
621}
622
623#[derive(Serialize)]
626pub struct Resource {
627 pub base_path: String,
633 pub nav_label: Text,
635 pub list: list::ListConfig,
636 pub form: Option<form::FormConfig>,
637 pub permission: Option<String>,
644}
645
646#[derive(Clone, Serialize)]
651pub struct Permission {
652 pub code: String,
653 pub label: Text,
655 pub group: Text,
656}
657
658fn builtin_permissions() -> Vec<Permission> {
661 vec![
662 Permission {
663 code: "backend.manage_users".to_string(),
664 label: "Manage backend users".into(),
665 group: "Backend".into(),
666 },
667 Permission {
668 code: "backend.manage_roles".to_string(),
669 label: "Manage roles".into(),
670 group: "Backend".into(),
671 },
672 Permission {
673 code: "backend.manage_branding".to_string(),
674 label: "Manage branding".into(),
675 group: "Backend".into(),
676 },
677 Permission {
678 code: plugins::MANAGE_PERMISSION.to_string(),
679 label: "Manage plugins".into(),
680 group: "Backend".into(),
681 },
682 Permission {
683 code: "backend.view_audit_log".to_string(),
684 label: "View the audit log".into(),
685 group: "Backend".into(),
686 },
687 ]
688}
689
690pub fn descriptor_sources() -> Vec<String> {
696 let mut out = Vec::new();
697 out.extend(laterite_core::collect_sources(&builtin_resources()));
698 out.extend(laterite_core::collect_sources(&builtin_settings()));
699 out.extend(laterite_core::collect_sources(&builtin_permissions()));
700 out.sort();
701 out.dedup();
702 out
703}
704
705pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
720 builtin_modules().iter().map(|m| m.migrations()).collect()
721}
722
723pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
727 vec![
728 Box::new(laterite_auth::AuthModule),
729 Box::new(settings::SettingsModule),
730 Box::new(plugins::PluginsModule),
731 ]
732}
733
734#[allow(clippy::too_many_arguments)]
742pub fn router(
743 auth: AuthService,
744 db: Db,
745 app_resources: Vec<Resource>,
746 app_settings: Vec<settings::SettingsItem>,
747 app_permissions: Vec<Permission>,
748 app_picker_sources: Vec<picker::PickerSourceReg>,
749 app_persisters: Vec<persist::PersisterReg>,
750 config: AdminConfig,
751 catalogs: Arc<CatalogStore>,
752) -> Router {
753 let admin_path = normalize_path(&config.path);
754
755 let mut resources = builtin_resources();
756 let mut app_resources = app_resources;
757 for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
761 prefix_resource(&admin_path, resource);
762 }
763
764 let mut settings = builtin_settings();
765 settings.extend(app_settings);
766 let mut permissions = builtin_permissions();
767 permissions.extend(app_permissions);
768
769 let mut nav = vec![NavLink {
773 label: "Dashboard".into(),
774 path: admin_path.clone(),
775 icon: Some("layout-dashboard"),
776 }];
777 for resource in &app_resources {
778 nav.push(NavLink {
779 label: resource.nav_label.clone(),
780 path: resource.base_path.clone(),
781 icon: None,
782 });
783 }
784 nav.push(NavLink {
785 label: "Settings".into(),
786 path: format!("{admin_path}/settings"),
787 icon: Some("settings"),
788 });
789 resources.extend(app_resources);
790
791 let app_name = if config.app_name.trim().is_empty() {
792 "Laterite".to_string()
793 } else {
794 config.app_name.clone()
795 };
796
797 let mut pickers = picker::PickerRegistry::new();
800 for reg in app_picker_sources {
801 let name = reg.name.clone();
802 if !field::is_name(&name, true) {
803 panic!("invalid picker source name `{name}`");
804 }
805 if pickers.insert(name.clone(), reg).is_some() {
806 panic!("duplicate picker source `{name}`");
807 }
808 }
809
810 let pickers = Arc::new(pickers);
814 let mut field_types = field::builtin_registry();
815 field_types.insert(
816 "reference".to_string(),
817 Arc::new(field::RefPickerField::new(pickers.clone())),
818 );
819
820 let mut persisters = persist::PersisterRegistry::new();
823 for reg in app_persisters {
824 let name = reg.name.clone();
825 if !field::is_name(&name, true) {
826 panic!("invalid persister name `{name}`");
827 }
828 if persisters.insert(name.clone(), reg.persister).is_some() {
829 panic!("duplicate persister `{name}`");
830 }
831 }
832
833 let state = AdminState {
834 auth,
835 db,
836 nav: Arc::new(nav),
837 settings: Arc::new(settings),
838 permissions: Arc::new(permissions),
839 admin_path: Arc::from(admin_path.as_str()),
840 secure_cookie: config.secure_cookie,
841 origin: Arc::from(config.origin.trim_end_matches('/')),
842 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
843 default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
844 catalogs,
845 app_name,
846 brand_cache: Arc::new(RwLock::new(None)),
847 field_types: Arc::new(field_types),
848 column_types: Arc::new(list::builtin_column_registry()),
849 assets: Arc::new(builtin_assets()),
850 pickers,
851 overrides: Arc::new(field::NoOverrides),
852 };
853
854 let mut protected = Router::new().route(&admin_path, get(dashboard));
855 for resource in &resources {
856 protected = protected.merge(mount_resource(resource, &state.field_types, &persisters));
857 }
858 protected = protected.merge(guard_with_permission(
861 Router::new()
862 .route(
863 &format!("{admin_path}/roles/new"),
864 get(roles::new_form).post(roles::create),
865 )
866 .route(
867 &format!("{admin_path}/roles/{{id}}/edit"),
868 get(roles::edit_form).post(roles::update),
869 ),
870 "backend.manage_roles",
871 ));
872 protected = protected.merge(guard_with_permission(
875 Router::new().route(
876 &format!("{admin_path}/users/{{id}}/edit"),
877 get(users::edit_form).post(users::update),
878 ),
879 "backend.manage_users",
880 ));
881 protected = protected.merge(guard_with_permission(
884 Router::new()
885 .route(&format!("{admin_path}/plugins"), get(plugins::index))
886 .route(
887 &format!("{admin_path}/plugins/toggle"),
888 post(plugins::toggle),
889 ),
890 plugins::MANAGE_PERMISSION,
891 ));
892 protected = protected
893 .route(&format!("{admin_path}/settings"), get(settings_index))
894 .route(
895 &format!("{admin_path}/settings/{{code}}"),
896 get(settings_edit).post(settings_update),
897 )
898 .route(
899 &format!("{admin_path}/preferences"),
900 get(preferences_form).post(preferences_update),
901 )
902 .route(
905 &format!("{admin_path}/pickers/{{source}}/search"),
906 any(picker::search),
907 )
908 .route(
909 &format!("{admin_path}/pickers/{{source}}/resolve"),
910 any(picker::resolve),
911 )
912 .route(&format!("{admin_path}/logout"), post(logout));
913
914 protected
915 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
916 .route(
920 &format!("{admin_path}/login"),
921 get(login_form).post(login_submit),
922 )
923 .route(
924 &format!("{admin_path}/setup"),
925 get(setup_form).post(setup_submit),
926 )
927 .route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
928 .fallback(not_found_fallback)
930 .layer(middleware::from_fn_with_state(
933 state.clone(),
934 enforce_origin,
935 ))
936 .layer(tower_http::catch_panic::CatchPanicLayer::custom(
937 handle_panic,
938 ))
939 .with_state(state)
940}
941
942async fn not_found_fallback() -> AdminError {
944 AdminError::NotFound
945}
946
947fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
949 let cause = err
950 .downcast_ref::<&str>()
951 .map(|s| s.to_string())
952 .or_else(|| err.downcast_ref::<String>().cloned())
953 .unwrap_or_else(|| "unknown panic".to_string());
954 tracing::error!(panic = cause, "admin handler panicked");
955 error::masked_500()
956}
957
958pub fn normalize_path(path: &str) -> String {
963 let trimmed = path.trim().trim_matches('/');
964 if trimmed.is_empty() {
965 "/admin".to_string()
966 } else {
967 format!("/{trimmed}")
968 }
969}
970
971fn prefix_resource(admin_path: &str, resource: &mut Resource) {
974 resource.base_path = format!("{admin_path}{}", resource.base_path);
975 if let Some(edit_base) = &mut resource.list.edit_base {
976 *edit_base = format!("{admin_path}{edit_base}");
977 }
978 if let Some(form) = &mut resource.form {
979 form.base_path = format!("{admin_path}{}", form.base_path);
980 }
981}
982
983pub(crate) struct AdminAsset {
985 pub mime: &'static str,
986 pub cache: &'static str,
987 pub bytes: &'static [u8],
988}
989
990pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
993
994pub(crate) fn page_assets(keys: &[&str], base: &str, registry: &AssetRegistry) -> Vec<PageAsset> {
1001 let mut seen = std::collections::HashSet::new();
1002 let mut out = Vec::new();
1003 for &key in keys {
1004 if !seen.insert(key) {
1005 continue;
1006 }
1007 match registry.get(key) {
1008 Some(asset) => out.push(PageAsset {
1009 url: format!("{base}/assets/{key}"),
1010 css: asset.mime.contains("css"),
1011 }),
1012 None => debug_assert!(false, "asset key `{key}` is not registered"),
1013 }
1014 }
1015 out
1016}
1017
1018const ASSET_IMMUTABLE: &str = "public, max-age=31536000, immutable";
1019
1020pub(crate) fn builtin_assets() -> AssetRegistry {
1024 let font = |bytes: &'static [u8]| AdminAsset {
1025 mime: "font/woff2",
1026 cache: ASSET_IMMUTABLE,
1027 bytes,
1028 };
1029 HashMap::from([
1030 (
1031 "laterite.css",
1032 AdminAsset {
1033 mime: "text/css; charset=utf-8",
1034 cache: "no-cache",
1035 bytes: include_bytes!("../assets/laterite.css"),
1036 },
1037 ),
1038 (
1039 "laterite.js",
1040 AdminAsset {
1041 mime: "text/javascript; charset=utf-8",
1042 cache: "no-cache",
1043 bytes: include_bytes!("../assets/laterite.js"),
1044 },
1045 ),
1046 (
1047 "vendor/htmx.min.js",
1048 AdminAsset {
1049 mime: "text/javascript; charset=utf-8",
1050 cache: "no-cache",
1051 bytes: include_bytes!("../assets/vendor/htmx.min.js"),
1052 },
1053 ),
1054 (
1055 "fields/ref-picker.js",
1056 AdminAsset {
1057 mime: "text/javascript; charset=utf-8",
1058 cache: "no-cache",
1059 bytes: include_bytes!("../assets/fields/ref-picker.js"),
1060 },
1061 ),
1062 (
1063 "fields/ref-picker.css",
1064 AdminAsset {
1065 mime: "text/css; charset=utf-8",
1066 cache: "no-cache",
1067 bytes: include_bytes!("../assets/fields/ref-picker.css"),
1068 },
1069 ),
1070 (
1071 "mark.svg",
1072 AdminAsset {
1073 mime: "image/svg+xml",
1074 cache: ASSET_IMMUTABLE,
1075 bytes: include_bytes!("../assets/mark.svg"),
1076 },
1077 ),
1078 (
1079 "mark.png",
1080 AdminAsset {
1081 mime: "image/png",
1082 cache: ASSET_IMMUTABLE,
1083 bytes: include_bytes!("../assets/mark.png"),
1084 },
1085 ),
1086 (
1087 "fonts/space-grotesk-500.woff2",
1088 font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
1089 ),
1090 (
1091 "fonts/space-grotesk-600.woff2",
1092 font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
1093 ),
1094 (
1095 "fonts/space-grotesk-700.woff2",
1096 font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
1097 ),
1098 (
1099 "fonts/ibm-plex-sans-400.woff2",
1100 font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
1101 ),
1102 (
1103 "fonts/ibm-plex-sans-600.woff2",
1104 font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
1105 ),
1106 (
1107 "fonts/ibm-plex-mono-400.woff2",
1108 font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
1109 ),
1110 (
1111 "fonts/ibm-plex-mono-600.woff2",
1112 font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
1113 ),
1114 ])
1115}
1116
1117async fn serve_asset(State(state): State<AdminState>, Path(path): Path<String>) -> Response {
1119 match state.assets.get(path.as_str()) {
1120 Some(asset) => (
1121 [
1122 (header::CONTENT_TYPE, asset.mime),
1123 (header::CACHE_CONTROL, asset.cache),
1124 ],
1125 asset.bytes,
1126 )
1127 .into_response(),
1128 None => not_found(),
1129 }
1130}
1131
1132fn mount_resource(
1137 resource: &Resource,
1138 field_types: &field::FieldRegistry,
1139 persisters: &persist::PersisterRegistry,
1140) -> Router<AdminState> {
1141 if resource.form.is_some() && resource.permission.is_none() {
1146 panic!(
1147 "admin resource `{}` has a create/edit form but no permission; a write \
1148 resource must declare a permission",
1149 resource.base_path
1150 );
1151 }
1152 let base = resource.base_path.clone();
1153 let list_cfg = resource.list.clone();
1154 let mut router = Router::new().route(
1155 &base,
1156 get(
1157 move |State(state): State<AdminState>,
1158 Extension(shell): Extension<Shell>,
1159 Query(params): Query<list::ListParams>| {
1160 let cfg = list_cfg.clone();
1161 async move { list::handle(&state, &cfg, params, shell).await }
1162 },
1163 ),
1164 );
1165
1166 if let Some(form_cfg) = resource.form.clone() {
1167 let prepared = Arc::new(
1170 form::PreparedForm::prepare(form_cfg, field_types, persisters)
1171 .unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
1172 );
1173 let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
1174 router = router.route(
1175 &format!("{base}/new"),
1176 get(
1177 move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
1178 let pf = new_pf.clone();
1179 async move { form::new_form(&state, &pf, shell) }
1180 },
1181 )
1182 .post(
1183 move |State(state): State<AdminState>,
1184 Extension(shell): Extension<Shell>,
1185 Extension(user): Extension<AuthenticatedUser>,
1186 Form(data): Form<HashMap<String, String>>| {
1187 let pf = create_pf.clone();
1188 async move { form::create(&state, &pf, data, shell, &user).await }
1189 },
1190 ),
1191 );
1192
1193 let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
1194 router = router.route(
1195 &format!("{base}/{{id}}/edit"),
1196 get(
1197 move |State(state): State<AdminState>,
1198 Extension(shell): Extension<Shell>,
1199 Path(id): Path<String>| {
1200 let pf = edit_pf.clone();
1201 async move { form::edit_form(&state, &pf, id, shell).await }
1202 },
1203 )
1204 .post(
1205 move |State(state): State<AdminState>,
1206 Extension(shell): Extension<Shell>,
1207 Extension(user): Extension<AuthenticatedUser>,
1208 Path(id): Path<String>,
1209 Form(data): Form<HashMap<String, String>>| {
1210 let pf = update_pf.clone();
1211 async move { form::update(&state, &pf, id, data, shell, &user).await }
1212 },
1213 ),
1214 );
1215 }
1216
1217 if let Some(permission) = &resource.permission {
1219 router = guard_with_permission(router, permission);
1220 }
1221 router
1222}
1223
1224fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
1229 let needed: Arc<str> = Arc::from(permission);
1230 router.route_layer(middleware::from_fn(
1231 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
1232 let needed = needed.clone();
1233 async move {
1234 if user.allows(&needed) {
1235 next.run(req).await
1236 } else {
1237 forbidden()
1238 }
1239 }
1240 },
1241 ))
1242}
1243
1244async fn require_auth(
1250 State(state): State<AdminState>,
1251 jar: CookieJar,
1252 request: Request,
1253 next: Next,
1254) -> Response {
1255 let login = format!("{}/login", state.admin_path);
1256 let token = match jar.get(SESSION_COOKIE) {
1257 Some(cookie) => cookie.value().to_string(),
1258 None => return Redirect::to(&login).into_response(),
1259 };
1260 let resolved = match state.auth.resolve_session(&token).await {
1261 Ok(resolved) => resolved,
1262 Err(_) => return Redirect::to(&login).into_response(),
1263 };
1264 let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
1265
1266 let mut request = request;
1271 let safe = session::is_safe_method(request.method());
1272 if !safe {
1273 let (parts, body) = request.into_parts();
1274 let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
1275 .await
1276 .unwrap_or_default();
1277 let submitted = session::submitted_token(&parts.headers, &bytes);
1278 if !session::token_matches(&handle.csrf_token(), submitted.as_deref()) {
1279 tracing::warn!(
1280 user_id = resolved.identity.user.id,
1281 "admin CSRF check failed"
1282 );
1283 return error::csrf_rejected();
1284 }
1285 request = Request::from_parts(parts, Body::from(bytes));
1286 }
1287
1288 let flash = if safe {
1291 handle.take_flash()
1292 } else {
1293 Vec::new()
1294 };
1295 let user = resolved.identity;
1296 let path = request.uri().path().to_string();
1297 let accept_language = request
1301 .headers()
1302 .get("accept-language")
1303 .and_then(|v| v.to_str().ok());
1304 let serveable = state.catalogs.locales();
1305 let chain = resolve_locale_chain(
1306 user.user.locale.as_deref(),
1307 accept_language,
1308 &state.default_locale,
1309 &serveable,
1310 );
1311 let i18n = Translator::with_chain(chain, state.catalogs.clone());
1312 let translator = i18n.clone();
1315 let (sidebar, active_nav) = resolve_nav_context(
1316 &state.nav,
1317 &state.settings,
1318 &state.admin_path,
1319 &user.permissions,
1320 &path,
1321 &i18n,
1322 );
1323 let brand = state.brand().await;
1324 let shell = Shell::new(
1325 &state.admin_path,
1326 brand,
1327 &state.nav,
1328 &user,
1329 state.timezone,
1330 sidebar,
1331 active_nav.as_deref(),
1332 handle.csrf_token(),
1333 flash,
1334 i18n,
1335 );
1336 request.extensions_mut().insert(user);
1337 request.extensions_mut().insert(shell);
1338 request.extensions_mut().insert(handle.clone());
1339 let mut response = next.run(request).await;
1340
1341 if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
1345 response = error::localized_error(meta.kind, &translator);
1346 }
1347
1348 if let Some(blob) = handle.dirty_blob() {
1351 if let Err(e) = state.auth.set_session_data(&token, &blob).await {
1352 tracing::error!(error = %e, "persisting admin session failed");
1353 }
1354 }
1355 response
1356}
1357
1358const MAX_FORM_BYTES: usize = 1024 * 1024;
1361
1362async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
1367 if !session::is_safe_method(request.method())
1368 && !session::origin_ok(request.headers(), &state.origin)
1369 {
1370 tracing::warn!(
1371 path = request.uri().path(),
1372 "admin CSRF origin check failed"
1373 );
1374 return error::csrf_rejected();
1375 }
1376 next.run(request).await
1377}
1378
1379async fn login_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
1380 match state.auth.has_any_operator().await {
1382 Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
1383 Ok(true) => render(LoginTemplate {
1384 base: state.admin_path.to_string(),
1385 brand: state.brand().await,
1386 error: None,
1387 i18n: pre_auth_translator(&state, &headers),
1388 }),
1389 Err(_) => render_error(),
1390 }
1391}
1392
1393#[derive(Deserialize)]
1394struct LoginForm {
1395 username: String,
1396 password: String,
1397}
1398
1399async fn login_submit(
1400 State(state): State<AdminState>,
1401 jar: CookieJar,
1402 headers: HeaderMap,
1403 Form(form): Form<LoginForm>,
1404) -> Response {
1405 match state
1406 .auth
1407 .authenticate(&form.username, &form.password, &RequestContext::default())
1408 .await
1409 {
1410 Ok(session) => {
1411 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
1412 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
1413 }
1414 Err(_) => {
1415 let i18n = pre_auth_translator(&state, &headers);
1416 render(LoginTemplate {
1417 base: state.admin_path.to_string(),
1418 brand: state.brand().await,
1419 error: Some(i18n.t(&t!("Invalid username or password."))),
1420 i18n,
1421 })
1422 }
1423 }
1424}
1425
1426fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
1429 Cookie::build((SESSION_COOKIE, token))
1430 .path(admin_path.to_string())
1431 .http_only(true)
1432 .secure(secure)
1433 .same_site(SameSite::Lax)
1434 .build()
1435}
1436
1437#[derive(Deserialize)]
1438struct SetupForm {
1439 username: String,
1440 first_name: String,
1441 last_name: String,
1442 email: String,
1443 password: String,
1444 timezone: String,
1445}
1446
1447async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
1450 match state.auth.has_any_operator().await {
1451 Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1452 Ok(false) => render(setup_view(
1453 &state.admin_path,
1454 state.brand().await,
1455 state.timezone,
1456 None,
1457 pre_auth_translator(&state, &headers),
1458 )),
1459 Err(_) => render_error(),
1460 }
1461}
1462
1463async fn setup_submit(
1464 State(state): State<AdminState>,
1465 jar: CookieJar,
1466 headers: HeaderMap,
1467 Form(form): Form<SetupForm>,
1468) -> Response {
1469 match state.auth.has_any_operator().await {
1471 Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1472 Ok(false) => {}
1473 Err(_) => return render_error(),
1474 }
1475
1476 let username = form.username.trim();
1477 let email = form.email.trim();
1478 let first_name = form.first_name.trim();
1479 let last_name = form.last_name.trim();
1480 let tz = form.timezone.trim();
1481 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
1482 {
1483 return render(setup_view(
1484 &state.admin_path,
1485 state.brand().await,
1486 state.timezone,
1487 Some(t!(
1488 "Username, first name, email, and password are all required."
1489 )),
1490 pre_auth_translator(&state, &headers),
1491 ));
1492 }
1493 if tz.parse::<Tz>().is_err() {
1495 return render(setup_view(
1496 &state.admin_path,
1497 state.brand().await,
1498 state.timezone,
1499 Some(t!("That is not a recognised timezone.")),
1500 pre_auth_translator(&state, &headers),
1501 ));
1502 }
1503
1504 let new = NewOperator {
1505 username,
1506 email,
1507 first_name,
1508 last_name: (!last_name.is_empty()).then_some(last_name),
1509 password: &form.password,
1510 timezone: Some(tz),
1511 };
1512 if state.auth.create_superuser(new).await.is_err() {
1513 return render(setup_view(
1514 &state.admin_path,
1515 state.brand().await,
1516 state.timezone,
1517 Some(t!(
1518 "Could not create the account. The username or email may already be taken."
1519 )),
1520 pre_auth_translator(&state, &headers),
1521 ));
1522 }
1523
1524 match state
1526 .auth
1527 .authenticate(username, &form.password, &RequestContext::default())
1528 .await
1529 {
1530 Ok(session) => {
1531 let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
1532 (jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
1533 }
1534 Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
1535 }
1536}
1537
1538fn setup_view(
1541 admin_path: &str,
1542 brand: String,
1543 default_tz: Tz,
1544 error: Option<Text>,
1545 i18n: Translator,
1546) -> SetupTemplate {
1547 let default_name = default_tz.name();
1548 let zones = TZ_VARIANTS
1549 .iter()
1550 .filter(|tz| zone_offered(tz.name()))
1551 .map(|tz| TzOption {
1552 name: tz.name().to_string(),
1553 selected: tz.name() == default_name,
1554 })
1555 .collect();
1556 SetupTemplate {
1557 base: admin_path.to_string(),
1558 brand,
1559 zones,
1560 error: error.map(|e| i18n.t(&e)),
1561 i18n,
1562 }
1563}
1564
1565async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
1566 if let Some(cookie) = jar.get(SESSION_COOKIE) {
1567 let _ = state.auth.logout(cookie.value()).await;
1568 }
1569 let removal = Cookie::build((SESSION_COOKIE, ""))
1570 .path(state.admin_path.to_string())
1571 .build();
1572 (
1573 jar.remove(removal),
1574 Redirect::to(&format!("{}/login", state.admin_path)),
1575 )
1576 .into_response()
1577}
1578
1579async fn dashboard(
1580 Extension(shell): Extension<Shell>,
1581 Extension(user): Extension<AuthenticatedUser>,
1582) -> Response {
1583 render(DashboardTemplate {
1584 username: user.user.username,
1585 shell,
1586 })
1587}
1588
1589fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
1592 match &item.permission {
1593 None => true,
1594 Some(p) => perms.allows(p),
1595 }
1596}
1597
1598fn visible_settings(
1602 items: &[settings::SettingsItem],
1603 perms: &PermissionSet,
1604) -> Vec<settings::SettingsItem> {
1605 items
1606 .iter()
1607 .filter(|item| operator_can_see(item, perms))
1608 .cloned()
1609 .collect()
1610}
1611
1612async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
1613 settings::index(shell)
1614}
1615
1616async fn settings_edit(
1617 State(state): State<AdminState>,
1618 Extension(shell): Extension<Shell>,
1619 Extension(user): Extension<AuthenticatedUser>,
1620 Path(code): Path<String>,
1621) -> Response {
1622 let items = visible_settings(&state.settings, &user.permissions);
1625 match items
1626 .iter()
1627 .find(|item| item.code == code && item.link.is_none())
1628 {
1629 Some(item) => settings::edit_form(&state, item, shell).await,
1630 None => not_found(),
1631 }
1632}
1633
1634async fn settings_update(
1635 State(state): State<AdminState>,
1636 Extension(shell): Extension<Shell>,
1637 Extension(user): Extension<AuthenticatedUser>,
1638 Extension(session): Extension<session::SessionHandle>,
1639 Path(code): Path<String>,
1640 Form(data): Form<HashMap<String, String>>,
1641) -> Response {
1642 let items = visible_settings(&state.settings, &user.permissions);
1643 match items
1644 .iter()
1645 .find(|item| item.code == code && item.link.is_none())
1646 {
1647 Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
1648 None => not_found(),
1649 }
1650}
1651
1652async fn preferences_form(
1654 State(state): State<AdminState>,
1655 Extension(shell): Extension<Shell>,
1656 Extension(user): Extension<AuthenticatedUser>,
1657) -> Response {
1658 render(preferences_view(
1659 &shell,
1660 &user,
1661 state.timezone,
1662 &state.default_locale,
1663 &offered_locales(&state.catalogs),
1664 None,
1665 ))
1666}
1667
1668#[derive(Deserialize)]
1669struct PreferencesForm {
1670 timezone: String,
1671 #[serde(default)]
1673 locale: String,
1674}
1675
1676async fn preferences_update(
1677 State(state): State<AdminState>,
1678 Extension(shell): Extension<Shell>,
1679 Extension(user): Extension<AuthenticatedUser>,
1680 Extension(session): Extension<session::SessionHandle>,
1681 Form(form): Form<PreferencesForm>,
1682) -> Response {
1683 let offered = offered_locales(&state.catalogs);
1684 let tz = form.timezone.trim();
1686 let tz_stored = if tz.is_empty() {
1687 None
1688 } else if tz.parse::<Tz>().is_ok() {
1689 Some(tz)
1690 } else {
1691 return render(preferences_view(
1692 &shell,
1693 &user,
1694 state.timezone,
1695 &state.default_locale,
1696 &offered,
1697 Some(t!("That is not a recognised timezone.")),
1698 ));
1699 };
1700 let loc = form.locale.trim();
1701 let loc_stored = if loc.is_empty() {
1702 None
1703 } else if offered.iter().any(|l| l == loc) {
1704 Some(loc)
1705 } else {
1706 return render(preferences_view(
1707 &shell,
1708 &user,
1709 state.timezone,
1710 &state.default_locale,
1711 &offered,
1712 Some(t!("That is not a supported language.")),
1713 ));
1714 };
1715 if state
1716 .auth
1717 .set_user_timezone(user.user.id, tz_stored)
1718 .await
1719 .is_err()
1720 {
1721 return render_error();
1722 }
1723 match state.auth.set_user_locale(user.user.id, loc_stored).await {
1724 Ok(()) => {
1725 session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
1726 Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
1727 }
1728 Err(_) => render_error(),
1729 }
1730}
1731
1732fn preferences_view(
1736 shell: &Shell,
1737 user: &AuthenticatedUser,
1738 default_tz: Tz,
1739 default_locale: &str,
1740 offered: &[String],
1741 error: Option<Text>,
1742) -> PreferencesTemplate {
1743 let current = user.user.timezone.as_deref();
1744 let zones = TZ_VARIANTS
1745 .iter()
1746 .filter(|tz| zone_offered(tz.name()))
1747 .map(|tz| TzOption {
1748 name: tz.name().to_string(),
1749 selected: current == Some(tz.name()),
1750 })
1751 .collect();
1752 let current_locale = user.user.locale.as_deref();
1753 let locales = offered
1754 .iter()
1755 .map(|code| LocaleOption {
1756 name: locale_name(code),
1757 selected: current_locale == Some(code.as_str()),
1758 code: code.clone(),
1759 })
1760 .collect();
1761 PreferencesTemplate {
1762 shell: shell.clone(),
1763 zones,
1764 effective_tz: shell.tz.name().to_string(),
1765 default_tz: default_tz.name().to_string(),
1766 inherits: current.is_none(),
1767 locales,
1768 default_locale: locale_name(default_locale),
1769 inherits_locale: current_locale.is_none(),
1770 error: error.map(|e| shell.tt(&e)),
1771 }
1772}
1773
1774fn builtin_resources() -> Vec<Resource> {
1776 vec![
1777 Resource {
1778 base_path: "/users".to_string(),
1779 nav_label: "Backend Users".into(),
1780 list: backend_users_list_config(),
1781 form: None,
1782 permission: Some("backend.manage_users".to_string()),
1783 },
1784 Resource {
1785 base_path: "/roles".to_string(),
1786 nav_label: "Roles".into(),
1787 list: roles_list_config(),
1788 form: None,
1791 permission: Some("backend.manage_roles".to_string()),
1792 },
1793 Resource {
1794 base_path: "/audit-log".to_string(),
1795 nav_label: "Audit Log".into(),
1796 list: audit_log_list_config(),
1797 form: None,
1799 permission: Some("backend.view_audit_log".to_string()),
1800 },
1801 ]
1802}
1803
1804fn builtin_settings() -> Vec<settings::SettingsItem> {
1808 vec![
1809 settings::SettingsItem {
1810 code: "backend.administrators".to_string(),
1811 label: "Administrators".into(),
1812 description: "Manage backend administrator accounts.".into(),
1813 category: "Users".into(),
1814 order: 10,
1815 icon: Some("users".to_string()),
1816 permission: Some("backend.manage_users".to_string()),
1817 link: Some("/users".to_string()),
1818 fields: Vec::new(),
1819 },
1820 settings::SettingsItem {
1821 code: "backend.roles".to_string(),
1822 label: "Roles".into(),
1823 description: "Manage roles and their permissions.".into(),
1824 category: "Users".into(),
1825 order: 20,
1826 icon: Some("shield".to_string()),
1827 permission: Some("backend.manage_roles".to_string()),
1828 link: Some("/roles".to_string()),
1829 fields: Vec::new(),
1830 },
1831 settings::brand::settings_item(),
1832 settings::SettingsItem {
1833 code: "backend.plugins".to_string(),
1834 label: "Plugins".into(),
1835 description: "Enable or disable installed plugins.".into(),
1836 category: "System".into(),
1837 order: 10,
1838 icon: Some("plug".to_string()),
1839 permission: Some(plugins::MANAGE_PERMISSION.to_string()),
1840 link: Some("/plugins".to_string()),
1841 fields: Vec::new(),
1842 },
1843 settings::SettingsItem {
1844 code: "backend.audit_log".to_string(),
1845 label: "Audit Log".into(),
1846 description: "Review the record of administrative changes.".into(),
1847 category: "System".into(),
1848 order: 20,
1849 icon: Some("history".to_string()),
1850 permission: Some("backend.view_audit_log".to_string()),
1851 link: Some("/audit-log".to_string()),
1852 fields: Vec::new(),
1853 },
1854 ]
1855}
1856
1857fn backend_users_list_config() -> list::ListConfig {
1858 list::ListConfig {
1859 entity: "backend_users".to_string(),
1860 title: "Backend Users".into(),
1861 columns: vec![
1862 list::ListColumn::new("username", "Username"),
1863 list::ListColumn::new("email", "Email"),
1864 list::ListColumn::new("first_name", "First name"),
1865 list::ListColumn::new("last_name", "Last name"),
1866 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
1867 list::ListColumn::new("is_active", "Active").yes_no(),
1868 list::ListColumn::new("created_at", "Created").datetime(),
1869 ],
1870 order_by: "created_at".to_string(),
1871 order_dir: list::SortDir::Desc,
1872 per_page: 25,
1873 id_field: "id".to_string(),
1874 edit_base: Some("/users".to_string()),
1877 creatable: false,
1878 }
1879}
1880
1881fn roles_list_config() -> list::ListConfig {
1882 list::ListConfig {
1883 entity: "backend_roles".to_string(),
1884 title: "Roles".into(),
1885 columns: vec![
1886 list::ListColumn::new("code", "Code"),
1887 list::ListColumn::new("name", "Name"),
1888 list::ListColumn::new("created_at", "Created").datetime(),
1889 ],
1890 order_by: "created_at".to_string(),
1891 order_dir: list::SortDir::Desc,
1892 per_page: 25,
1893 id_field: "id".to_string(),
1894 edit_base: Some("/roles".to_string()),
1895 creatable: true,
1896 }
1897}
1898
1899fn audit_log_list_config() -> list::ListConfig {
1902 list::ListConfig {
1903 entity: "backend_audit_log".to_string(),
1904 title: "Audit Log".into(),
1905 columns: vec![
1906 list::ListColumn::new("created_at", "When").datetime(),
1907 list::ListColumn::new("actor_username", "Operator"),
1908 list::ListColumn::new("action", "Action"),
1909 list::ListColumn::new("target_type", "Target"),
1910 list::ListColumn::new("target_id", "Target ID"),
1911 ],
1912 order_by: "created_at".to_string(),
1913 order_dir: list::SortDir::Desc,
1914 per_page: 50,
1915 id_field: "id".to_string(),
1916 edit_base: None,
1917 creatable: false,
1918 }
1919}
1920
1921#[derive(Template)]
1922#[template(path = "login.html")]
1923struct LoginTemplate {
1924 base: String,
1926 brand: String,
1927 error: Option<String>,
1928 i18n: Translator,
1931}
1932
1933impl LoginTemplate {
1934 fn t(&self, source: &str) -> String {
1935 self.i18n.t(&Text::dynamic(source))
1936 }
1937 fn locale(&self) -> &str {
1938 self.i18n.locale()
1939 }
1940}
1941
1942#[derive(Template)]
1943#[template(path = "dashboard.html")]
1944struct DashboardTemplate {
1945 shell: Shell,
1946 username: String,
1947}
1948
1949#[derive(Template)]
1950#[template(path = "setup.html")]
1951struct SetupTemplate {
1952 base: String,
1954 brand: String,
1955 zones: Vec<TzOption>,
1956 error: Option<String>,
1957 i18n: Translator,
1959}
1960
1961impl SetupTemplate {
1962 fn t(&self, source: &str) -> String {
1963 self.i18n.t(&Text::dynamic(source))
1964 }
1965 fn locale(&self) -> &str {
1966 self.i18n.locale()
1967 }
1968}
1969
1970#[derive(Template)]
1971#[template(path = "preferences.html")]
1972struct PreferencesTemplate {
1973 shell: Shell,
1974 zones: Vec<TzOption>,
1975 effective_tz: String,
1977 default_tz: String,
1979 inherits: bool,
1981 locales: Vec<LocaleOption>,
1982 default_locale: String,
1984 inherits_locale: bool,
1986 error: Option<String>,
1987}
1988
1989struct TzOption {
1990 name: String,
1991 selected: bool,
1992}
1993
1994struct LocaleOption {
1995 code: String,
1997 name: String,
1999 selected: bool,
2000}
2001
2002#[derive(Clone)]
2003struct NavView {
2004 label: String,
2005 path: String,
2006 active: bool,
2007 icon: &'static str,
2010}
2011
2012pub(crate) fn render<T: Template>(template: T) -> Response {
2014 match template.render() {
2015 Ok(html) => Html(html).into_response(),
2016 Err(e) => AdminError::from(e).into_response(),
2018 }
2019}
2020
2021pub(crate) fn render_error() -> Response {
2022 error::masked_500()
2023}
2024
2025pub(crate) fn not_found() -> Response {
2026 AdminError::NotFound.into_response()
2027}
2028
2029pub(crate) fn forbidden() -> Response {
2030 AdminError::Forbidden.into_response()
2031}
2032
2033#[cfg(test)]
2034mod tests {
2035 use super::*;
2036
2037 fn write_form() -> form::FormConfig {
2038 form::FormConfig {
2039 entity: "widgets".to_string(),
2040 title: "Widgets".into(),
2041 base_path: "/widgets".to_string(),
2042 id_field: "id".to_string(),
2043 fields: vec![],
2044 persist: None,
2045 }
2046 }
2047
2048 fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
2049 Resource {
2050 base_path: "/widgets".to_string(),
2051 nav_label: "Widgets".into(),
2052 list: backend_users_list_config(),
2053 form,
2054 permission: permission.map(str::to_string),
2055 }
2056 }
2057
2058 #[test]
2059 #[should_panic(expected = "must declare a permission")]
2060 fn write_resource_without_permission_aborts_boot() {
2061 let resource = resource_with(Some(write_form()), None);
2064 let _ = mount_resource(
2065 &resource,
2066 &field::builtin_registry(),
2067 &persist::PersisterRegistry::new(),
2068 );
2069 }
2070
2071 #[test]
2072 fn read_only_resource_may_omit_permission() {
2073 let resource = resource_with(None, None);
2075 let _ = mount_resource(
2076 &resource,
2077 &field::builtin_registry(),
2078 &persist::PersisterRegistry::new(),
2079 );
2080 }
2081
2082 #[test]
2083 fn shell_localizes_through_the_translator_and_falls_back() {
2084 let shell = Shell::test();
2086 assert_eq!(shell.t("Save"), "Save");
2087 assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
2088 }
2089
2090 #[test]
2091 fn shell_tf_interpolates_integer_arguments() {
2092 let shell = Shell::test();
2093 assert_eq!(
2094 shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
2095 "Page 2 of 5"
2096 );
2097 }
2098
2099 #[test]
2100 fn display_tz_prefers_a_valid_operator_preference() {
2101 assert_eq!(
2102 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
2103 Tz::Asia__Kolkata
2104 );
2105 }
2106
2107 #[test]
2108 fn display_tz_falls_back_when_unset_or_invalid() {
2109 let default = Tz::Europe__London;
2110 assert_eq!(resolve_display_tz(None, default), default);
2112 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
2114 assert_eq!(resolve_display_tz(Some(""), default), default);
2115 }
2116
2117 #[test]
2118 fn accept_language_orders_by_weight_and_drops_wildcard() {
2119 assert_eq!(
2120 parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
2121 vec!["en-US", "en", "kn"]
2122 );
2123 assert_eq!(
2125 parse_accept_language("en;q=0.5,kn,*;q=0.1"),
2126 vec!["kn", "en"]
2127 );
2128 }
2129
2130 #[test]
2131 fn locale_chain_prefers_operator_then_header_then_default() {
2132 let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
2134 assert_eq!(
2137 resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
2138 vec!["kn", "ta", "hi", "en"]
2139 );
2140 assert_eq!(
2142 resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
2143 vec!["kn", "en", "ta"]
2144 );
2145 assert_eq!(
2147 resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
2148 vec!["kn", "en"]
2149 );
2150 }
2151
2152 #[test]
2153 fn locale_chain_skips_unserveable_and_defaults_to_en() {
2154 let loaded = ["hi".to_string()];
2156 assert_eq!(
2159 resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
2160 vec!["en"]
2161 );
2162 assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
2164 assert_eq!(
2166 resolve_locale_chain(Some("hi"), None, "en", &loaded),
2167 vec!["hi", "en"]
2168 );
2169 assert_eq!(
2171 resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
2172 vec![laterite_core::PSEUDO_LOCALE, "en"]
2173 );
2174 }
2175
2176 #[test]
2177 fn page_assets_dedupe_preserve_order_and_classify() {
2178 let mut reg = AssetRegistry::new();
2179 reg.insert(
2180 "a.js",
2181 AdminAsset {
2182 mime: "text/javascript; charset=utf-8",
2183 cache: "no-cache",
2184 bytes: b"",
2185 },
2186 );
2187 reg.insert(
2188 "b.css",
2189 AdminAsset {
2190 mime: "text/css; charset=utf-8",
2191 cache: "no-cache",
2192 bytes: b"",
2193 },
2194 );
2195 let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®);
2198 assert_eq!(assets.len(), 2);
2199 assert_eq!(assets[0].url, "/admin/assets/a.js");
2200 assert!(!assets[0].css);
2201 assert_eq!(assets[1].url, "/admin/assets/b.css");
2202 assert!(assets[1].css);
2203 }
2204
2205 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
2209 laterite_core::testing::connect_test(&[]).await
2210 }
2211
2212 #[tokio::test]
2213 async fn builtin_migrations_create_the_admin_tables() {
2214 let (db, _guard) = empty_db().await;
2215 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
2216 .await
2217 .unwrap();
2218 for table in ["backend_users", "settings"] {
2222 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
2223 .fetch_optional(&db.pool)
2224 .await;
2225 assert!(
2226 probe.is_ok(),
2227 "{table} should exist after builtin_migrations"
2228 );
2229 }
2230 }
2231
2232 #[tokio::test]
2233 async fn brand_setting_overrides_config_and_blank_falls_back() {
2234 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
2235 let state = AdminState {
2236 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
2237 db: db.clone(),
2238 nav: Arc::new(Vec::new()),
2239 settings: Arc::new(Vec::new()),
2240 permissions: Arc::new(builtin_permissions()),
2241 admin_path: Arc::from("/admin"),
2242 secure_cookie: false,
2243 origin: Arc::from(""),
2244 timezone: Tz::UTC,
2245 default_locale: Arc::from("en"),
2246 catalogs: Arc::new(CatalogStore::default()),
2247 app_name: "Configured Name".to_string(),
2248 brand_cache: Arc::new(RwLock::new(None)),
2249 field_types: Arc::new(field::builtin_registry()),
2250 column_types: Arc::new(list::builtin_column_registry()),
2251 assets: Arc::new(builtin_assets()),
2252 pickers: Arc::new(picker::PickerRegistry::new()),
2253 overrides: Arc::new(field::NoOverrides),
2254 };
2255
2256 assert_eq!(state.brand().await, "Configured Name");
2258
2259 settings::store::save(
2262 &db,
2263 &settings::BrandSetting {
2264 app_name: "Acme Corp".to_string(),
2265 },
2266 )
2267 .await
2268 .unwrap();
2269 state.invalidate_brand();
2270 assert_eq!(state.brand().await, "Acme Corp");
2271
2272 settings::store::save(
2274 &db,
2275 &settings::BrandSetting {
2276 app_name: " ".to_string(),
2277 },
2278 )
2279 .await
2280 .unwrap();
2281 state.invalidate_brand();
2282 assert_eq!(state.brand().await, "Configured Name");
2283 }
2284
2285 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
2286 settings::SettingsItem {
2287 code: code.to_string(),
2288 label: code.into(),
2289 description: String::new().into(),
2290 category: "General".into(),
2291 order: 1,
2292 icon: None,
2293 permission: permission.map(str::to_string),
2294 link: None,
2295 fields: Vec::new(),
2296 }
2297 }
2298
2299 #[test]
2300 fn settings_visibility_respects_permissions() {
2301 let items = vec![
2302 settings_item("public", None),
2303 settings_item("gated", Some("backend.manage_users")),
2304 ];
2305
2306 let none = PermissionSet::new(false, Vec::<String>::new());
2308 let codes: Vec<String> = visible_settings(&items, &none)
2309 .into_iter()
2310 .map(|i| i.code)
2311 .collect();
2312 assert_eq!(codes, ["public"]);
2313
2314 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
2316 assert_eq!(visible_settings(&items, &granted).len(), 2);
2317
2318 let superuser = PermissionSet::new(true, Vec::<String>::new());
2320 assert_eq!(visible_settings(&items, &superuser).len(), 2);
2321 }
2322
2323 #[test]
2324 fn context_sidebar_follows_settings_links() {
2325 let items = builtin_settings();
2326 let superuser = PermissionSet::new(true, Vec::<String>::new());
2327 let tr = Translator::new("en");
2328 let sidebar =
2329 |path: &str| resolve_nav_context(&[], &items, "/admin", &superuser, path, &tr).0;
2330 let active_path = |path: &str| -> Option<String> {
2331 sidebar(path)
2332 .into_iter()
2333 .flat_map(|g| g.items)
2334 .find(|i| i.active)
2335 .map(|i| i.path)
2336 };
2337
2338 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
2340 assert_eq!(
2341 active_path("/admin/roles/42/edit").as_deref(),
2342 Some("/admin/roles")
2343 );
2344 assert!(!sidebar("/admin/settings").is_empty());
2346 assert_eq!(active_path("/admin/settings"), None);
2347 assert_eq!(
2349 active_path("/admin/settings/backend.roles").as_deref(),
2350 Some("/admin/roles")
2351 );
2352 assert!(sidebar("/admin").is_empty());
2354 }
2355
2356 #[test]
2357 fn active_nav_lights_the_right_tab() {
2358 let nav = vec![
2359 NavLink {
2360 label: "Dashboard".into(),
2361 path: "/admin".to_string(),
2362 icon: Some("layout-dashboard"),
2363 },
2364 NavLink {
2365 label: "Pages".into(),
2366 path: "/admin/pages".to_string(),
2367 icon: None,
2368 },
2369 NavLink {
2370 label: "Settings".into(),
2371 path: "/admin/settings".to_string(),
2372 icon: Some("settings"),
2373 },
2374 ];
2375
2376 assert_eq!(
2378 active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
2379 Some("/admin")
2380 );
2381 assert_eq!(
2383 active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
2384 Some("/admin/pages")
2385 );
2386 assert_eq!(
2388 active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
2389 None
2390 );
2391 assert_eq!(
2394 active_nav_path(&nav, "/admin", false, "/admin/preferences"),
2395 None
2396 );
2397 assert_eq!(
2400 active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
2401 Some("/admin/settings")
2402 );
2403 assert_eq!(
2404 active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
2405 Some("/admin/settings")
2406 );
2407 }
2408}