mod audit;
pub mod bootstrap;
mod error;
pub mod field;
pub mod form;
pub mod html;
mod icons;
pub mod list;
pub mod persist;
pub mod picker;
pub mod plugins;
mod roles;
mod session;
pub mod settings;
mod sql;
mod users;
pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
pub use error::AdminError;
pub use session::{FlashLevel, SessionHandle};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use askama::Template;
use axum::body::Body;
use axum::extract::{Path, Query, Request, State};
use axum::http::{header, HeaderMap};
use axum::middleware::{self, Next};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{any, get, post};
use axum::{Extension, Form, Router};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use chrono_tz::{Tz, TZ_VARIANTS};
use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
use laterite_core::{t, CatalogStore, Db, Text, Translator};
use serde::{Deserialize, Serialize};
pub trait AdminRegistry {
fn add_resource(&mut self, resource: Resource);
fn add_permission(&mut self, permission: Permission);
fn add_settings(&mut self, item: settings::SettingsItem);
fn add_picker_source(&mut self, source: picker::PickerSourceReg);
fn add_persister(&mut self, persister: persist::PersisterReg);
}
impl AdminRegistry for laterite_core::Registry {
fn add_resource(&mut self, resource: Resource) {
self.add(resource);
}
fn add_permission(&mut self, permission: Permission) {
self.add(permission);
}
fn add_settings(&mut self, item: settings::SettingsItem) {
self.add(item);
}
fn add_picker_source(&mut self, source: picker::PickerSourceReg) {
self.add(source);
}
fn add_persister(&mut self, persister: persist::PersisterReg) {
self.add(persister);
}
}
const SESSION_COOKIE: &str = "laterite_session";
#[derive(Clone)]
pub(crate) struct AdminState {
auth: AuthService,
db: Db,
nav: Arc<Vec<NavLink>>,
settings: Arc<Vec<settings::SettingsItem>>,
permissions: Arc<Vec<Permission>>,
admin_path: Arc<str>,
secure_cookie: bool,
origin: Arc<str>,
timezone: Tz,
default_locale: Arc<str>,
catalogs: Arc<CatalogStore>,
app_name: String,
brand_cache: Arc<RwLock<Option<String>>>,
field_types: Arc<field::FieldRegistry>,
column_types: Arc<list::ColumnRegistry>,
assets: Arc<AssetRegistry>,
pickers: Arc<picker::PickerRegistry>,
overrides: Arc<dyn field::OverrideResolver>,
}
impl AdminState {
#[cfg(test)]
pub(crate) fn new(auth: AuthService, db: Db) -> Self {
Self {
auth,
db,
nav: Arc::new(Vec::new()),
settings: Arc::new(Vec::new()),
permissions: Arc::new(builtin_permissions()),
admin_path: Arc::from("/admin"),
secure_cookie: false,
origin: Arc::from(""),
timezone: Tz::UTC,
default_locale: Arc::from("en"),
catalogs: Arc::new(CatalogStore::default()),
app_name: "Laterite".to_string(),
brand_cache: Arc::new(RwLock::new(None)),
field_types: Arc::new(field::builtin_registry()),
column_types: Arc::new(list::builtin_column_registry()),
assets: Arc::new(builtin_assets()),
pickers: Arc::new(picker::PickerRegistry::new()),
overrides: Arc::new(field::NoOverrides),
}
}
async fn brand(&self) -> String {
{
let cached = self.brand_cache.read().unwrap().clone();
if let Some(name) = cached {
return name;
}
}
let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
_ => self.app_name.clone(),
};
*self.brand_cache.write().unwrap() = Some(resolved.clone());
resolved
}
fn invalidate_brand(&self) {
*self.brand_cache.write().unwrap() = None;
}
}
#[derive(Clone)]
#[non_exhaustive]
pub struct AdminConfig {
pub secure_cookie: bool,
pub timezone: String,
pub locale: String,
pub app_name: String,
pub path: String,
pub origin: String,
}
impl Default for AdminConfig {
fn default() -> Self {
Self {
secure_cookie: false,
timezone: "UTC".to_string(),
locale: "en".to_string(),
app_name: "Laterite".to_string(),
path: "/admin".to_string(),
origin: String::new(),
}
}
}
#[derive(Clone)]
struct NavLink {
label: Text,
path: String,
icon: Option<&'static str>,
}
#[derive(Clone)]
pub(crate) struct Shell {
pub(crate) base: String,
brand: String,
nav: Vec<NavView>,
full_name: String,
initial: String,
tz: Tz,
i18n: Translator,
sidebar: Vec<settings::CategoryView>,
pub(crate) csrf_token: String,
pub(crate) flash: Vec<FlashLine>,
pub(crate) assets: Vec<PageAsset>,
}
#[derive(Clone)]
pub(crate) struct PageAsset {
pub(crate) url: String,
pub(crate) css: bool,
}
#[derive(Clone)]
pub(crate) struct FlashLine {
pub(crate) level: session::FlashLevel,
pub(crate) text: String,
}
impl Shell {
#[allow(clippy::too_many_arguments)]
fn new(
base: &str,
brand: String,
nav: &[NavLink],
user: &AuthenticatedUser,
default_tz: Tz,
sidebar: Vec<settings::CategoryView>,
active_nav: Option<&str>,
csrf_token: String,
flash: Vec<session::Flash>,
i18n: Translator,
) -> Self {
let full_name = user.user.full_name();
let initial = full_name
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
let flash = flash
.into_iter()
.map(|f| FlashLine {
level: f.level,
text: i18n.t(&f.text),
})
.collect();
let nav = nav
.iter()
.map(|n| NavView {
label: i18n.t(&n.label),
path: n.path.clone(),
active: active_nav == Some(n.path.as_str()),
icon: n.icon.map(|name| icons::svg(Some(name))).unwrap_or(""),
})
.collect();
Shell {
base: base.to_string(),
brand,
nav,
full_name,
initial,
tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
i18n,
sidebar,
csrf_token,
flash,
assets: Vec::new(),
}
}
pub(crate) fn t(&self, source: &str) -> String {
self.i18n.t(&Text::dynamic(source))
}
pub(crate) fn tt(&self, text: &Text) -> String {
self.i18n.t(text)
}
pub(crate) fn locale(&self) -> &str {
self.i18n.locale()
}
pub(crate) fn tf(&self, source: &str, args: &[(&'static str, i64)]) -> String {
let mut text = Text::dynamic(source);
for (name, value) in args {
text = text.arg(*name, *value);
}
self.i18n.t(&text)
}
pub(crate) fn tfs(&self, source: &str, args: &[(&'static str, &str)]) -> String {
let mut text = Text::dynamic(source);
for (name, value) in args {
text = text.arg(*name, *value);
}
self.i18n.t(&text)
}
#[cfg(test)]
pub(crate) fn test() -> Self {
Shell {
base: "/admin".to_string(),
brand: "Laterite".to_string(),
nav: Vec::new(),
full_name: "Test Operator".to_string(),
initial: "T".to_string(),
tz: Tz::UTC,
i18n: Translator::new("en"),
sidebar: Vec::new(),
csrf_token: "test-csrf-token".to_string(),
flash: Vec::new(),
assets: Vec::new(),
}
}
}
fn settings_context(
visible: &[settings::SettingsItem],
admin_path: &str,
path: &str,
) -> (bool, Option<String>) {
let settings_root = format!("{admin_path}/settings");
if path == settings_root {
(true, None)
} else if let Some(code) = path.strip_prefix(&format!("{settings_root}/")) {
(true, Some(code.to_string()))
} else {
let active = visible
.iter()
.filter_map(|i| {
i.link
.as_deref()
.map(|link| (format!("{admin_path}{link}"), &i.code))
})
.filter(|(link, _)| path == link.as_str() || path.starts_with(&format!("{link}/")))
.max_by_key(|(link, _)| link.len())
.map(|(_, code)| code.clone());
(active.is_some(), active)
}
}
fn active_nav_path(
nav: &[NavLink],
admin_path: &str,
in_settings_context: bool,
path: &str,
) -> Option<String> {
if in_settings_context {
return Some(format!("{admin_path}/settings"));
}
nav.iter()
.filter(|n| {
path == n.path || (n.path != admin_path && path.starts_with(&format!("{}/", n.path)))
})
.max_by_key(|n| n.path.len())
.map(|n| n.path.clone())
}
fn resolve_nav_context(
nav: &[NavLink],
items: &[settings::SettingsItem],
admin_path: &str,
perms: &PermissionSet,
path: &str,
tr: &Translator,
) -> (Vec<settings::CategoryView>, Option<String>) {
let visible = visible_settings(items, perms);
let (in_context, active) = settings_context(&visible, admin_path, path);
let sidebar = if in_context {
settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr)
} else {
Vec::new()
};
let active_nav = active_nav_path(nav, admin_path, in_context, path);
(sidebar, active_nav)
}
fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
preference
.and_then(|name| name.parse::<Tz>().ok())
.unwrap_or(default_tz)
}
fn zone_offered(name: &str) -> bool {
!matches!(name, "Asia/Jerusalem" | "Asia/Tel_Aviv")
}
fn base_lang(tag: &str) -> String {
tag.split(['-', '_'])
.next()
.unwrap_or(tag)
.to_ascii_lowercase()
}
fn is_serveable(base: &str, serveable: &[String]) -> bool {
base == "en" || base == laterite_core::PSEUDO_LOCALE || serveable.iter().any(|l| l == base)
}
fn default_locale(configured: &str, serveable: &[String]) -> String {
let base = base_lang(configured);
if is_serveable(&base, serveable) {
base
} else {
"en".to_string()
}
}
fn offered_locales(catalogs: &CatalogStore) -> Vec<String> {
let mut out = vec!["en".to_string()];
out.extend(catalogs.locales());
out
}
fn locale_name(code: &str) -> String {
if code == "en" {
"English".to_string()
} else {
code.to_string()
}
}
fn parse_accept_language(header: &str) -> Vec<String> {
let mut items: Vec<(f32, String)> = header
.split(',')
.filter_map(|part| {
let mut bits = part.split(';');
let tag = bits.next()?.trim();
if tag.is_empty() || tag == "*" {
return None;
}
let q = bits
.find_map(|p| p.trim().strip_prefix("q="))
.and_then(|q| q.parse::<f32>().ok())
.unwrap_or(1.0);
Some((q, tag.to_string()))
})
.collect();
items.sort_by(|a, b| b.0.total_cmp(&a.0));
items.into_iter().map(|(_, tag)| tag).collect()
}
fn resolve_locale_chain(
preference: Option<&str>,
accept_language: Option<&str>,
default: &str,
serveable: &[String],
) -> Vec<String> {
let mut chain: Vec<String> = Vec::new();
let consider = |tag: &str, chain: &mut Vec<String>| {
let base = base_lang(tag);
if is_serveable(&base, serveable) && !chain.contains(&base) {
chain.push(base);
}
};
if let Some(pref) = preference {
consider(pref, &mut chain);
}
if let Some(header) = accept_language {
for tag in parse_accept_language(header) {
consider(&tag, &mut chain);
}
}
consider(default, &mut chain);
consider("en", &mut chain);
chain
}
fn pre_auth_translator(state: &AdminState, headers: &HeaderMap) -> Translator {
let accept = headers.get("accept-language").and_then(|v| v.to_str().ok());
let serveable = state.catalogs.locales();
let chain = resolve_locale_chain(None, accept, &state.default_locale, &serveable);
Translator::with_chain(chain, state.catalogs.clone())
}
#[derive(Serialize)]
pub struct Resource {
pub base_path: String,
pub nav_label: Text,
pub list: list::ListConfig,
pub form: Option<form::FormConfig>,
pub permission: Option<String>,
}
#[derive(Clone, Serialize)]
pub struct Permission {
pub code: String,
pub label: Text,
pub group: Text,
}
fn builtin_permissions() -> Vec<Permission> {
vec![
Permission {
code: "backend.manage_users".to_string(),
label: "Manage backend users".into(),
group: "Backend".into(),
},
Permission {
code: "backend.manage_roles".to_string(),
label: "Manage roles".into(),
group: "Backend".into(),
},
Permission {
code: "backend.manage_branding".to_string(),
label: "Manage branding".into(),
group: "Backend".into(),
},
Permission {
code: plugins::MANAGE_PERMISSION.to_string(),
label: "Manage plugins".into(),
group: "Backend".into(),
},
Permission {
code: "backend.view_audit_log".to_string(),
label: "View the audit log".into(),
group: "Backend".into(),
},
]
}
pub fn descriptor_sources() -> Vec<String> {
let mut out = Vec::new();
out.extend(laterite_core::collect_sources(&builtin_resources()));
out.extend(laterite_core::collect_sources(&builtin_settings()));
out.extend(laterite_core::collect_sources(&builtin_permissions()));
out.sort();
out.dedup();
out
}
pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
builtin_modules().iter().map(|m| m.migrations()).collect()
}
pub fn builtin_modules() -> Vec<Box<dyn laterite_core::Module>> {
vec![
Box::new(laterite_auth::AuthModule),
Box::new(settings::SettingsModule),
Box::new(plugins::PluginsModule),
]
}
#[allow(clippy::too_many_arguments)]
pub fn router(
auth: AuthService,
db: Db,
app_resources: Vec<Resource>,
app_settings: Vec<settings::SettingsItem>,
app_permissions: Vec<Permission>,
app_picker_sources: Vec<picker::PickerSourceReg>,
app_persisters: Vec<persist::PersisterReg>,
config: AdminConfig,
catalogs: Arc<CatalogStore>,
) -> Router {
let admin_path = normalize_path(&config.path);
let mut resources = builtin_resources();
let mut app_resources = app_resources;
for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
prefix_resource(&admin_path, resource);
}
let mut settings = builtin_settings();
settings.extend(app_settings);
let mut permissions = builtin_permissions();
permissions.extend(app_permissions);
let mut nav = vec![NavLink {
label: "Dashboard".into(),
path: admin_path.clone(),
icon: Some("layout-dashboard"),
}];
for resource in &app_resources {
nav.push(NavLink {
label: resource.nav_label.clone(),
path: resource.base_path.clone(),
icon: None,
});
}
nav.push(NavLink {
label: "Settings".into(),
path: format!("{admin_path}/settings"),
icon: Some("settings"),
});
resources.extend(app_resources);
let app_name = if config.app_name.trim().is_empty() {
"Laterite".to_string()
} else {
config.app_name.clone()
};
let mut pickers = picker::PickerRegistry::new();
for reg in app_picker_sources {
let name = reg.name.clone();
if !field::is_name(&name, true) {
panic!("invalid picker source name `{name}`");
}
if pickers.insert(name.clone(), reg).is_some() {
panic!("duplicate picker source `{name}`");
}
}
let pickers = Arc::new(pickers);
let mut field_types = field::builtin_registry();
field_types.insert(
"reference".to_string(),
Arc::new(field::RefPickerField::new(pickers.clone())),
);
let mut persisters = persist::PersisterRegistry::new();
for reg in app_persisters {
let name = reg.name.clone();
if !field::is_name(&name, true) {
panic!("invalid persister name `{name}`");
}
if persisters.insert(name.clone(), reg.persister).is_some() {
panic!("duplicate persister `{name}`");
}
}
let state = AdminState {
auth,
db,
nav: Arc::new(nav),
settings: Arc::new(settings),
permissions: Arc::new(permissions),
admin_path: Arc::from(admin_path.as_str()),
secure_cookie: config.secure_cookie,
origin: Arc::from(config.origin.trim_end_matches('/')),
timezone: config.timezone.parse().unwrap_or(Tz::UTC),
default_locale: Arc::from(default_locale(&config.locale, &catalogs.locales())),
catalogs,
app_name,
brand_cache: Arc::new(RwLock::new(None)),
field_types: Arc::new(field_types),
column_types: Arc::new(list::builtin_column_registry()),
assets: Arc::new(builtin_assets()),
pickers,
overrides: Arc::new(field::NoOverrides),
};
let mut protected = Router::new().route(&admin_path, get(dashboard));
for resource in &resources {
protected = protected.merge(mount_resource(resource, &state.field_types, &persisters));
}
protected = protected.merge(guard_with_permission(
Router::new()
.route(
&format!("{admin_path}/roles/new"),
get(roles::new_form).post(roles::create),
)
.route(
&format!("{admin_path}/roles/{{id}}/edit"),
get(roles::edit_form).post(roles::update),
),
"backend.manage_roles",
));
protected = protected.merge(guard_with_permission(
Router::new().route(
&format!("{admin_path}/users/{{id}}/edit"),
get(users::edit_form).post(users::update),
),
"backend.manage_users",
));
protected = protected.merge(guard_with_permission(
Router::new()
.route(&format!("{admin_path}/plugins"), get(plugins::index))
.route(
&format!("{admin_path}/plugins/toggle"),
post(plugins::toggle),
),
plugins::MANAGE_PERMISSION,
));
protected = protected
.route(&format!("{admin_path}/settings"), get(settings_index))
.route(
&format!("{admin_path}/settings/{{code}}"),
get(settings_edit).post(settings_update),
)
.route(
&format!("{admin_path}/preferences"),
get(preferences_form).post(preferences_update),
)
.route(
&format!("{admin_path}/pickers/{{source}}/search"),
any(picker::search),
)
.route(
&format!("{admin_path}/pickers/{{source}}/resolve"),
any(picker::resolve),
)
.route(&format!("{admin_path}/logout"), post(logout));
protected
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
.route(
&format!("{admin_path}/login"),
get(login_form).post(login_submit),
)
.route(
&format!("{admin_path}/setup"),
get(setup_form).post(setup_submit),
)
.route(&format!("{admin_path}/assets/{{*path}}"), get(serve_asset))
.fallback(not_found_fallback)
.layer(middleware::from_fn_with_state(
state.clone(),
enforce_origin,
))
.layer(tower_http::catch_panic::CatchPanicLayer::custom(
handle_panic,
))
.with_state(state)
}
async fn not_found_fallback() -> AdminError {
AdminError::NotFound
}
fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
let cause = err
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| err.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_string());
tracing::error!(panic = cause, "admin handler panicked");
error::masked_500()
}
pub fn normalize_path(path: &str) -> String {
let trimmed = path.trim().trim_matches('/');
if trimmed.is_empty() {
"/admin".to_string()
} else {
format!("/{trimmed}")
}
}
fn prefix_resource(admin_path: &str, resource: &mut Resource) {
resource.base_path = format!("{admin_path}{}", resource.base_path);
if let Some(edit_base) = &mut resource.list.edit_base {
*edit_base = format!("{admin_path}{edit_base}");
}
if let Some(form) = &mut resource.form {
form.base_path = format!("{admin_path}{}", form.base_path);
}
}
pub(crate) struct AdminAsset {
pub mime: &'static str,
pub cache: &'static str,
pub bytes: &'static [u8],
}
pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
pub(crate) fn page_assets(keys: &[&str], base: &str, registry: &AssetRegistry) -> Vec<PageAsset> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for &key in keys {
if !seen.insert(key) {
continue;
}
match registry.get(key) {
Some(asset) => out.push(PageAsset {
url: format!("{base}/assets/{key}"),
css: asset.mime.contains("css"),
}),
None => debug_assert!(false, "asset key `{key}` is not registered"),
}
}
out
}
const ASSET_IMMUTABLE: &str = "public, max-age=31536000, immutable";
pub(crate) fn builtin_assets() -> AssetRegistry {
let font = |bytes: &'static [u8]| AdminAsset {
mime: "font/woff2",
cache: ASSET_IMMUTABLE,
bytes,
};
HashMap::from([
(
"laterite.css",
AdminAsset {
mime: "text/css; charset=utf-8",
cache: "no-cache",
bytes: include_bytes!("../assets/laterite.css"),
},
),
(
"laterite.js",
AdminAsset {
mime: "text/javascript; charset=utf-8",
cache: "no-cache",
bytes: include_bytes!("../assets/laterite.js"),
},
),
(
"vendor/htmx.min.js",
AdminAsset {
mime: "text/javascript; charset=utf-8",
cache: "no-cache",
bytes: include_bytes!("../assets/vendor/htmx.min.js"),
},
),
(
"fields/ref-picker.js",
AdminAsset {
mime: "text/javascript; charset=utf-8",
cache: "no-cache",
bytes: include_bytes!("../assets/fields/ref-picker.js"),
},
),
(
"fields/ref-picker.css",
AdminAsset {
mime: "text/css; charset=utf-8",
cache: "no-cache",
bytes: include_bytes!("../assets/fields/ref-picker.css"),
},
),
(
"mark.svg",
AdminAsset {
mime: "image/svg+xml",
cache: ASSET_IMMUTABLE,
bytes: include_bytes!("../assets/mark.svg"),
},
),
(
"mark.png",
AdminAsset {
mime: "image/png",
cache: ASSET_IMMUTABLE,
bytes: include_bytes!("../assets/mark.png"),
},
),
(
"fonts/space-grotesk-500.woff2",
font(include_bytes!("../assets/fonts/space-grotesk-500.woff2")),
),
(
"fonts/space-grotesk-600.woff2",
font(include_bytes!("../assets/fonts/space-grotesk-600.woff2")),
),
(
"fonts/space-grotesk-700.woff2",
font(include_bytes!("../assets/fonts/space-grotesk-700.woff2")),
),
(
"fonts/ibm-plex-sans-400.woff2",
font(include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")),
),
(
"fonts/ibm-plex-sans-600.woff2",
font(include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")),
),
(
"fonts/ibm-plex-mono-400.woff2",
font(include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")),
),
(
"fonts/ibm-plex-mono-600.woff2",
font(include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")),
),
])
}
async fn serve_asset(State(state): State<AdminState>, Path(path): Path<String>) -> Response {
match state.assets.get(path.as_str()) {
Some(asset) => (
[
(header::CONTENT_TYPE, asset.mime),
(header::CACHE_CONTROL, asset.cache),
],
asset.bytes,
)
.into_response(),
None => not_found(),
}
}
fn mount_resource(
resource: &Resource,
field_types: &field::FieldRegistry,
persisters: &persist::PersisterRegistry,
) -> Router<AdminState> {
if resource.form.is_some() && resource.permission.is_none() {
panic!(
"admin resource `{}` has a create/edit form but no permission; a write \
resource must declare a permission",
resource.base_path
);
}
let base = resource.base_path.clone();
let list_cfg = resource.list.clone();
let mut router = Router::new().route(
&base,
get(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Query(params): Query<list::ListParams>| {
let cfg = list_cfg.clone();
async move { list::handle(&state, &cfg, params, shell).await }
},
),
);
if let Some(form_cfg) = resource.form.clone() {
let prepared = Arc::new(
form::PreparedForm::prepare(form_cfg, field_types, persisters)
.unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
);
let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
router = router.route(
&format!("{base}/new"),
get(
move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
let pf = new_pf.clone();
async move { form::new_form(&state, &pf, shell) }
},
)
.post(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Form(data): Form<HashMap<String, String>>| {
let pf = create_pf.clone();
async move { form::create(&state, &pf, data, shell, &user).await }
},
),
);
let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
router = router.route(
&format!("{base}/{{id}}/edit"),
get(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Path(id): Path<String>| {
let pf = edit_pf.clone();
async move { form::edit_form(&state, &pf, id, shell).await }
},
)
.post(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Path(id): Path<String>,
Form(data): Form<HashMap<String, String>>| {
let pf = update_pf.clone();
async move { form::update(&state, &pf, id, data, shell, &user).await }
},
),
);
}
if let Some(permission) = &resource.permission {
router = guard_with_permission(router, permission);
}
router
}
fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
let needed: Arc<str> = Arc::from(permission);
router.route_layer(middleware::from_fn(
move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
let needed = needed.clone();
async move {
if user.allows(&needed) {
next.run(req).await
} else {
forbidden()
}
}
},
))
}
async fn require_auth(
State(state): State<AdminState>,
jar: CookieJar,
request: Request,
next: Next,
) -> Response {
let login = format!("{}/login", state.admin_path);
let token = match jar.get(SESSION_COOKIE) {
Some(cookie) => cookie.value().to_string(),
None => return Redirect::to(&login).into_response(),
};
let resolved = match state.auth.resolve_session(&token).await {
Ok(resolved) => resolved,
Err(_) => return Redirect::to(&login).into_response(),
};
let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
let mut request = request;
let safe = session::is_safe_method(request.method());
if !safe {
let (parts, body) = request.into_parts();
let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
.await
.unwrap_or_default();
let submitted = session::submitted_token(&parts.headers, &bytes);
if !session::token_matches(&handle.csrf_token(), submitted.as_deref()) {
tracing::warn!(
user_id = resolved.identity.user.id,
"admin CSRF check failed"
);
return error::csrf_rejected();
}
request = Request::from_parts(parts, Body::from(bytes));
}
let flash = if safe {
handle.take_flash()
} else {
Vec::new()
};
let user = resolved.identity;
let path = request.uri().path().to_string();
let accept_language = request
.headers()
.get("accept-language")
.and_then(|v| v.to_str().ok());
let serveable = state.catalogs.locales();
let chain = resolve_locale_chain(
user.user.locale.as_deref(),
accept_language,
&state.default_locale,
&serveable,
);
let i18n = Translator::with_chain(chain, state.catalogs.clone());
let translator = i18n.clone();
let (sidebar, active_nav) = resolve_nav_context(
&state.nav,
&state.settings,
&state.admin_path,
&user.permissions,
&path,
&i18n,
);
let brand = state.brand().await;
let shell = Shell::new(
&state.admin_path,
brand,
&state.nav,
&user,
state.timezone,
sidebar,
active_nav.as_deref(),
handle.csrf_token(),
flash,
i18n,
);
request.extensions_mut().insert(user);
request.extensions_mut().insert(shell);
request.extensions_mut().insert(handle.clone());
let mut response = next.run(request).await;
if let Some(meta) = response.extensions().get::<error::ErrorMeta>().copied() {
response = error::localized_error(meta.kind, &translator);
}
if let Some(blob) = handle.dirty_blob() {
if let Err(e) = state.auth.set_session_data(&token, &blob).await {
tracing::error!(error = %e, "persisting admin session failed");
}
}
response
}
const MAX_FORM_BYTES: usize = 1024 * 1024;
async fn enforce_origin(State(state): State<AdminState>, request: Request, next: Next) -> Response {
if !session::is_safe_method(request.method())
&& !session::origin_ok(request.headers(), &state.origin)
{
tracing::warn!(
path = request.uri().path(),
"admin CSRF origin check failed"
);
return error::csrf_rejected();
}
next.run(request).await
}
async fn login_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
match state.auth.has_any_operator().await {
Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
Ok(true) => render(LoginTemplate {
base: state.admin_path.to_string(),
brand: state.brand().await,
error: None,
i18n: pre_auth_translator(&state, &headers),
}),
Err(_) => render_error(),
}
}
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
async fn login_submit(
State(state): State<AdminState>,
jar: CookieJar,
headers: HeaderMap,
Form(form): Form<LoginForm>,
) -> Response {
match state
.auth
.authenticate(&form.username, &form.password, &RequestContext::default())
.await
{
Ok(session) => {
let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
(jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
}
Err(_) => {
let i18n = pre_auth_translator(&state, &headers);
render(LoginTemplate {
base: state.admin_path.to_string(),
brand: state.brand().await,
error: Some(i18n.t(&t!("Invalid username or password."))),
i18n,
})
}
}
}
fn session_cookie(token: String, admin_path: &str, secure: bool) -> Cookie<'static> {
Cookie::build((SESSION_COOKIE, token))
.path(admin_path.to_string())
.http_only(true)
.secure(secure)
.same_site(SameSite::Lax)
.build()
}
#[derive(Deserialize)]
struct SetupForm {
username: String,
first_name: String,
last_name: String,
email: String,
password: String,
timezone: String,
}
async fn setup_form(State(state): State<AdminState>, headers: HeaderMap) -> Response {
match state.auth.has_any_operator().await {
Ok(true) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
Ok(false) => render(setup_view(
&state.admin_path,
state.brand().await,
state.timezone,
None,
pre_auth_translator(&state, &headers),
)),
Err(_) => render_error(),
}
}
async fn setup_submit(
State(state): State<AdminState>,
jar: CookieJar,
headers: HeaderMap,
Form(form): Form<SetupForm>,
) -> Response {
match state.auth.has_any_operator().await {
Ok(true) => return Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
Ok(false) => {}
Err(_) => return render_error(),
}
let username = form.username.trim();
let email = form.email.trim();
let first_name = form.first_name.trim();
let last_name = form.last_name.trim();
let tz = form.timezone.trim();
if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
{
return render(setup_view(
&state.admin_path,
state.brand().await,
state.timezone,
Some(t!(
"Username, first name, email, and password are all required."
)),
pre_auth_translator(&state, &headers),
));
}
if tz.parse::<Tz>().is_err() {
return render(setup_view(
&state.admin_path,
state.brand().await,
state.timezone,
Some(t!("That is not a recognised timezone.")),
pre_auth_translator(&state, &headers),
));
}
let new = NewOperator {
username,
email,
first_name,
last_name: (!last_name.is_empty()).then_some(last_name),
password: &form.password,
timezone: Some(tz),
};
if state.auth.create_superuser(new).await.is_err() {
return render(setup_view(
&state.admin_path,
state.brand().await,
state.timezone,
Some(t!(
"Could not create the account. The username or email may already be taken."
)),
pre_auth_translator(&state, &headers),
));
}
match state
.auth
.authenticate(username, &form.password, &RequestContext::default())
.await
{
Ok(session) => {
let cookie = session_cookie(session.token, &state.admin_path, state.secure_cookie);
(jar.add(cookie), Redirect::to(&state.admin_path)).into_response()
}
Err(_) => Redirect::to(&format!("{}/login", state.admin_path)).into_response(),
}
}
fn setup_view(
admin_path: &str,
brand: String,
default_tz: Tz,
error: Option<Text>,
i18n: Translator,
) -> SetupTemplate {
let default_name = default_tz.name();
let zones = TZ_VARIANTS
.iter()
.filter(|tz| zone_offered(tz.name()))
.map(|tz| TzOption {
name: tz.name().to_string(),
selected: tz.name() == default_name,
})
.collect();
SetupTemplate {
base: admin_path.to_string(),
brand,
zones,
error: error.map(|e| i18n.t(&e)),
i18n,
}
}
async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
if let Some(cookie) = jar.get(SESSION_COOKIE) {
let _ = state.auth.logout(cookie.value()).await;
}
let removal = Cookie::build((SESSION_COOKIE, ""))
.path(state.admin_path.to_string())
.build();
(
jar.remove(removal),
Redirect::to(&format!("{}/login", state.admin_path)),
)
.into_response()
}
async fn dashboard(
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
) -> Response {
render(DashboardTemplate {
username: user.user.username,
shell,
})
}
fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
match &item.permission {
None => true,
Some(p) => perms.allows(p),
}
}
fn visible_settings(
items: &[settings::SettingsItem],
perms: &PermissionSet,
) -> Vec<settings::SettingsItem> {
items
.iter()
.filter(|item| operator_can_see(item, perms))
.cloned()
.collect()
}
async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
settings::index(shell)
}
async fn settings_edit(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Path(code): Path<String>,
) -> Response {
let items = visible_settings(&state.settings, &user.permissions);
match items
.iter()
.find(|item| item.code == code && item.link.is_none())
{
Some(item) => settings::edit_form(&state, item, shell).await,
None => not_found(),
}
}
async fn settings_update(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
Path(code): Path<String>,
Form(data): Form<HashMap<String, String>>,
) -> Response {
let items = visible_settings(&state.settings, &user.permissions);
match items
.iter()
.find(|item| item.code == code && item.link.is_none())
{
Some(item) => settings::update(&state, item, data, shell, &session, &user).await,
None => not_found(),
}
}
async fn preferences_form(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
) -> Response {
render(preferences_view(
&shell,
&user,
state.timezone,
&state.default_locale,
&offered_locales(&state.catalogs),
None,
))
}
#[derive(Deserialize)]
struct PreferencesForm {
timezone: String,
#[serde(default)]
locale: String,
}
async fn preferences_update(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
Form(form): Form<PreferencesForm>,
) -> Response {
let offered = offered_locales(&state.catalogs);
let tz = form.timezone.trim();
let tz_stored = if tz.is_empty() {
None
} else if tz.parse::<Tz>().is_ok() {
Some(tz)
} else {
return render(preferences_view(
&shell,
&user,
state.timezone,
&state.default_locale,
&offered,
Some(t!("That is not a recognised timezone.")),
));
};
let loc = form.locale.trim();
let loc_stored = if loc.is_empty() {
None
} else if offered.iter().any(|l| l == loc) {
Some(loc)
} else {
return render(preferences_view(
&shell,
&user,
state.timezone,
&state.default_locale,
&offered,
Some(t!("That is not a supported language.")),
));
};
if state
.auth
.set_user_timezone(user.user.id, tz_stored)
.await
.is_err()
{
return render_error();
}
match state.auth.set_user_locale(user.user.id, loc_stored).await {
Ok(()) => {
session.push_flash(session::FlashLevel::Success, t!("Preferences saved."));
Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
}
Err(_) => render_error(),
}
}
fn preferences_view(
shell: &Shell,
user: &AuthenticatedUser,
default_tz: Tz,
default_locale: &str,
offered: &[String],
error: Option<Text>,
) -> PreferencesTemplate {
let current = user.user.timezone.as_deref();
let zones = TZ_VARIANTS
.iter()
.filter(|tz| zone_offered(tz.name()))
.map(|tz| TzOption {
name: tz.name().to_string(),
selected: current == Some(tz.name()),
})
.collect();
let current_locale = user.user.locale.as_deref();
let locales = offered
.iter()
.map(|code| LocaleOption {
name: locale_name(code),
selected: current_locale == Some(code.as_str()),
code: code.clone(),
})
.collect();
PreferencesTemplate {
shell: shell.clone(),
zones,
effective_tz: shell.tz.name().to_string(),
default_tz: default_tz.name().to_string(),
inherits: current.is_none(),
locales,
default_locale: locale_name(default_locale),
inherits_locale: current_locale.is_none(),
error: error.map(|e| shell.tt(&e)),
}
}
fn builtin_resources() -> Vec<Resource> {
vec![
Resource {
base_path: "/users".to_string(),
nav_label: "Backend Users".into(),
list: backend_users_list_config(),
form: None,
permission: Some("backend.manage_users".to_string()),
},
Resource {
base_path: "/roles".to_string(),
nav_label: "Roles".into(),
list: roles_list_config(),
form: None,
permission: Some("backend.manage_roles".to_string()),
},
Resource {
base_path: "/audit-log".to_string(),
nav_label: "Audit Log".into(),
list: audit_log_list_config(),
form: None,
permission: Some("backend.view_audit_log".to_string()),
},
]
}
fn builtin_settings() -> Vec<settings::SettingsItem> {
vec![
settings::SettingsItem {
code: "backend.administrators".to_string(),
label: "Administrators".into(),
description: "Manage backend administrator accounts.".into(),
category: "Users".into(),
order: 10,
icon: Some("users".to_string()),
permission: Some("backend.manage_users".to_string()),
link: Some("/users".to_string()),
fields: Vec::new(),
},
settings::SettingsItem {
code: "backend.roles".to_string(),
label: "Roles".into(),
description: "Manage roles and their permissions.".into(),
category: "Users".into(),
order: 20,
icon: Some("shield".to_string()),
permission: Some("backend.manage_roles".to_string()),
link: Some("/roles".to_string()),
fields: Vec::new(),
},
settings::brand::settings_item(),
settings::SettingsItem {
code: "backend.plugins".to_string(),
label: "Plugins".into(),
description: "Enable or disable installed plugins.".into(),
category: "System".into(),
order: 10,
icon: Some("plug".to_string()),
permission: Some(plugins::MANAGE_PERMISSION.to_string()),
link: Some("/plugins".to_string()),
fields: Vec::new(),
},
settings::SettingsItem {
code: "backend.audit_log".to_string(),
label: "Audit Log".into(),
description: "Review the record of administrative changes.".into(),
category: "System".into(),
order: 20,
icon: Some("history".to_string()),
permission: Some("backend.view_audit_log".to_string()),
link: Some("/audit-log".to_string()),
fields: Vec::new(),
},
]
}
fn backend_users_list_config() -> list::ListConfig {
list::ListConfig {
entity: "backend_users".to_string(),
title: "Backend Users".into(),
columns: vec![
list::ListColumn::new("username", "Username"),
list::ListColumn::new("email", "Email"),
list::ListColumn::new("first_name", "First name"),
list::ListColumn::new("last_name", "Last name"),
list::ListColumn::new("is_superuser", "Superuser").yes_no(),
list::ListColumn::new("is_active", "Active").yes_no(),
list::ListColumn::new("created_at", "Created").datetime(),
],
order_by: "created_at".to_string(),
order_dir: list::SortDir::Desc,
per_page: 25,
id_field: "id".to_string(),
edit_base: Some("/users".to_string()),
creatable: false,
}
}
fn roles_list_config() -> list::ListConfig {
list::ListConfig {
entity: "backend_roles".to_string(),
title: "Roles".into(),
columns: vec![
list::ListColumn::new("code", "Code"),
list::ListColumn::new("name", "Name"),
list::ListColumn::new("created_at", "Created").datetime(),
],
order_by: "created_at".to_string(),
order_dir: list::SortDir::Desc,
per_page: 25,
id_field: "id".to_string(),
edit_base: Some("/roles".to_string()),
creatable: true,
}
}
fn audit_log_list_config() -> list::ListConfig {
list::ListConfig {
entity: "backend_audit_log".to_string(),
title: "Audit Log".into(),
columns: vec![
list::ListColumn::new("created_at", "When").datetime(),
list::ListColumn::new("actor_username", "Operator"),
list::ListColumn::new("action", "Action"),
list::ListColumn::new("target_type", "Target"),
list::ListColumn::new("target_id", "Target ID"),
],
order_by: "created_at".to_string(),
order_dir: list::SortDir::Desc,
per_page: 50,
id_field: "id".to_string(),
edit_base: None,
creatable: false,
}
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
base: String,
brand: String,
error: Option<String>,
i18n: Translator,
}
impl LoginTemplate {
fn t(&self, source: &str) -> String {
self.i18n.t(&Text::dynamic(source))
}
fn locale(&self) -> &str {
self.i18n.locale()
}
}
#[derive(Template)]
#[template(path = "dashboard.html")]
struct DashboardTemplate {
shell: Shell,
username: String,
}
#[derive(Template)]
#[template(path = "setup.html")]
struct SetupTemplate {
base: String,
brand: String,
zones: Vec<TzOption>,
error: Option<String>,
i18n: Translator,
}
impl SetupTemplate {
fn t(&self, source: &str) -> String {
self.i18n.t(&Text::dynamic(source))
}
fn locale(&self) -> &str {
self.i18n.locale()
}
}
#[derive(Template)]
#[template(path = "preferences.html")]
struct PreferencesTemplate {
shell: Shell,
zones: Vec<TzOption>,
effective_tz: String,
default_tz: String,
inherits: bool,
locales: Vec<LocaleOption>,
default_locale: String,
inherits_locale: bool,
error: Option<String>,
}
struct TzOption {
name: String,
selected: bool,
}
struct LocaleOption {
code: String,
name: String,
selected: bool,
}
#[derive(Clone)]
struct NavView {
label: String,
path: String,
active: bool,
icon: &'static str,
}
pub(crate) fn render<T: Template>(template: T) -> Response {
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => AdminError::from(e).into_response(),
}
}
pub(crate) fn render_error() -> Response {
error::masked_500()
}
pub(crate) fn not_found() -> Response {
AdminError::NotFound.into_response()
}
pub(crate) fn forbidden() -> Response {
AdminError::Forbidden.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn write_form() -> form::FormConfig {
form::FormConfig {
entity: "widgets".to_string(),
title: "Widgets".into(),
base_path: "/widgets".to_string(),
id_field: "id".to_string(),
fields: vec![],
persist: None,
}
}
fn resource_with(form: Option<form::FormConfig>, permission: Option<&str>) -> Resource {
Resource {
base_path: "/widgets".to_string(),
nav_label: "Widgets".into(),
list: backend_users_list_config(),
form,
permission: permission.map(str::to_string),
}
}
#[test]
#[should_panic(expected = "must declare a permission")]
fn write_resource_without_permission_aborts_boot() {
let resource = resource_with(Some(write_form()), None);
let _ = mount_resource(
&resource,
&field::builtin_registry(),
&persist::PersisterRegistry::new(),
);
}
#[test]
fn read_only_resource_may_omit_permission() {
let resource = resource_with(None, None);
let _ = mount_resource(
&resource,
&field::builtin_registry(),
&persist::PersisterRegistry::new(),
);
}
#[test]
fn shell_localizes_through_the_translator_and_falls_back() {
let shell = Shell::test();
assert_eq!(shell.t("Save"), "Save");
assert_eq!(shell.tt(&Text::dynamic("Discard")), "Discard");
}
#[test]
fn shell_tf_interpolates_integer_arguments() {
let shell = Shell::test();
assert_eq!(
shell.tf("Page {page} of {pages}", &[("page", 2), ("pages", 5)]),
"Page 2 of 5"
);
}
#[test]
fn display_tz_prefers_a_valid_operator_preference() {
assert_eq!(
resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
Tz::Asia__Kolkata
);
}
#[test]
fn display_tz_falls_back_when_unset_or_invalid() {
let default = Tz::Europe__London;
assert_eq!(resolve_display_tz(None, default), default);
assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
assert_eq!(resolve_display_tz(Some(""), default), default);
}
#[test]
fn accept_language_orders_by_weight_and_drops_wildcard() {
assert_eq!(
parse_accept_language("en-US,en;q=0.9,kn;q=0.8"),
vec!["en-US", "en", "kn"]
);
assert_eq!(
parse_accept_language("en;q=0.5,kn,*;q=0.1"),
vec!["kn", "en"]
);
}
#[test]
fn locale_chain_prefers_operator_then_header_then_default() {
let loaded = ["hi".to_string(), "kn".to_string(), "ta".to_string()];
assert_eq!(
resolve_locale_chain(Some("kn"), Some("ta;q=0.9,hi;q=0.8"), "en", &loaded),
vec!["kn", "ta", "hi", "en"]
);
assert_eq!(
resolve_locale_chain(Some("kn"), Some("en;q=0.9"), "ta", &loaded),
vec!["kn", "en", "ta"]
);
assert_eq!(
resolve_locale_chain(Some("kn-IN"), None, "en", &loaded),
vec!["kn", "en"]
);
}
#[test]
fn locale_chain_skips_unserveable_and_defaults_to_en() {
let loaded = ["hi".to_string()];
assert_eq!(
resolve_locale_chain(Some("fr"), Some("de,es"), "zz", &loaded),
vec!["en"]
);
assert_eq!(resolve_locale_chain(None, None, "en", &loaded), vec!["en"]);
assert_eq!(
resolve_locale_chain(Some("hi"), None, "en", &loaded),
vec!["hi", "en"]
);
assert_eq!(
resolve_locale_chain(Some(laterite_core::PSEUDO_LOCALE), None, "en", &loaded),
vec![laterite_core::PSEUDO_LOCALE, "en"]
);
}
#[test]
fn page_assets_dedupe_preserve_order_and_classify() {
let mut reg = AssetRegistry::new();
reg.insert(
"a.js",
AdminAsset {
mime: "text/javascript; charset=utf-8",
cache: "no-cache",
bytes: b"",
},
);
reg.insert(
"b.css",
AdminAsset {
mime: "text/css; charset=utf-8",
cache: "no-cache",
bytes: b"",
},
);
let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®);
assert_eq!(assets.len(), 2);
assert_eq!(assets[0].url, "/admin/assets/a.js");
assert!(!assets[0].css);
assert_eq!(assets[1].url, "/admin/assets/b.css");
assert!(assets[1].css);
}
async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
laterite_core::testing::connect_test(&[]).await
}
#[tokio::test]
async fn builtin_migrations_create_the_admin_tables() {
let (db, _guard) = empty_db().await;
laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
.await
.unwrap();
for table in ["backend_users", "settings"] {
let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
.fetch_optional(&db.pool)
.await;
assert!(
probe.is_ok(),
"{table} should exist after builtin_migrations"
);
}
}
#[tokio::test]
async fn brand_setting_overrides_config_and_blank_falls_back() {
let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
let state = AdminState {
auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
db: db.clone(),
nav: Arc::new(Vec::new()),
settings: Arc::new(Vec::new()),
permissions: Arc::new(builtin_permissions()),
admin_path: Arc::from("/admin"),
secure_cookie: false,
origin: Arc::from(""),
timezone: Tz::UTC,
default_locale: Arc::from("en"),
catalogs: Arc::new(CatalogStore::default()),
app_name: "Configured Name".to_string(),
brand_cache: Arc::new(RwLock::new(None)),
field_types: Arc::new(field::builtin_registry()),
column_types: Arc::new(list::builtin_column_registry()),
assets: Arc::new(builtin_assets()),
pickers: Arc::new(picker::PickerRegistry::new()),
overrides: Arc::new(field::NoOverrides),
};
assert_eq!(state.brand().await, "Configured Name");
settings::store::save(
&db,
&settings::BrandSetting {
app_name: "Acme Corp".to_string(),
},
)
.await
.unwrap();
state.invalidate_brand();
assert_eq!(state.brand().await, "Acme Corp");
settings::store::save(
&db,
&settings::BrandSetting {
app_name: " ".to_string(),
},
)
.await
.unwrap();
state.invalidate_brand();
assert_eq!(state.brand().await, "Configured Name");
}
fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
settings::SettingsItem {
code: code.to_string(),
label: code.into(),
description: String::new().into(),
category: "General".into(),
order: 1,
icon: None,
permission: permission.map(str::to_string),
link: None,
fields: Vec::new(),
}
}
#[test]
fn settings_visibility_respects_permissions() {
let items = vec![
settings_item("public", None),
settings_item("gated", Some("backend.manage_users")),
];
let none = PermissionSet::new(false, Vec::<String>::new());
let codes: Vec<String> = visible_settings(&items, &none)
.into_iter()
.map(|i| i.code)
.collect();
assert_eq!(codes, ["public"]);
let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
assert_eq!(visible_settings(&items, &granted).len(), 2);
let superuser = PermissionSet::new(true, Vec::<String>::new());
assert_eq!(visible_settings(&items, &superuser).len(), 2);
}
#[test]
fn context_sidebar_follows_settings_links() {
let items = builtin_settings();
let superuser = PermissionSet::new(true, Vec::<String>::new());
let tr = Translator::new("en");
let sidebar =
|path: &str| resolve_nav_context(&[], &items, "/admin", &superuser, path, &tr).0;
let active_path = |path: &str| -> Option<String> {
sidebar(path)
.into_iter()
.flat_map(|g| g.items)
.find(|i| i.active)
.map(|i| i.path)
};
assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
assert_eq!(
active_path("/admin/roles/42/edit").as_deref(),
Some("/admin/roles")
);
assert!(!sidebar("/admin/settings").is_empty());
assert_eq!(active_path("/admin/settings"), None);
assert_eq!(
active_path("/admin/settings/backend.roles").as_deref(),
Some("/admin/roles")
);
assert!(sidebar("/admin").is_empty());
}
#[test]
fn active_nav_lights_the_right_tab() {
let nav = vec![
NavLink {
label: "Dashboard".into(),
path: "/admin".to_string(),
icon: Some("layout-dashboard"),
},
NavLink {
label: "Pages".into(),
path: "/admin/pages".to_string(),
icon: None,
},
NavLink {
label: "Settings".into(),
path: "/admin/settings".to_string(),
icon: Some("settings"),
},
];
assert_eq!(
active_nav_path(&nav, "/admin", false, "/admin").as_deref(),
Some("/admin")
);
assert_eq!(
active_nav_path(&nav, "/admin", false, "/admin/pages/7/edit").as_deref(),
Some("/admin/pages")
);
assert_eq!(
active_nav_path(&nav, "/admin", false, "/admin/pages-archive"),
None
);
assert_eq!(
active_nav_path(&nav, "/admin", false, "/admin/preferences"),
None
);
assert_eq!(
active_nav_path(&nav, "/admin", true, "/admin/users").as_deref(),
Some("/admin/settings")
);
assert_eq!(
active_nav_path(&nav, "/admin", true, "/admin/settings").as_deref(),
Some("/admin/settings")
);
}
}