mod audit;
pub mod bootstrap;
mod bulk;
pub mod checklist;
mod clientip;
pub mod descriptor;
mod error;
mod export;
pub mod field;
pub mod form;
pub mod html;
pub mod http_cache;
mod icons;
pub mod import;
pub(crate) mod keyed;
pub mod list;
pub mod medialib;
pub mod nav;
pub(crate) mod palette;
pub mod persist;
pub mod picker;
pub mod plugins;
pub mod relation;
pub(crate) mod requestlog;
mod roles;
mod routemap;
pub mod routes;
mod session;
pub mod settings;
mod sql;
pub mod ui;
mod upload;
mod users;
pub use axum;
pub use bootstrap::{AppConfig, Bootstrap, BootstrapCtx, DEFAULT_ENV_PREFIX};
pub use error::AdminError;
pub use session::{FlashLevel, SessionHandle};
pub use upload::VerifiedUpload;
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);
fn add_field_type(&mut self, field_type: field::FieldTypeReg);
fn add_screen(&mut self, screen: routes::ScreenReg);
fn add_public_route(&mut self, route: routes::PublicRouteReg);
fn add_nav(&mut self, item: nav::NavItem);
fn add_resource_hook(&mut self, hook: ResourceHook);
}
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_screen(&mut self, screen: routes::ScreenReg) {
self.add(screen);
}
fn add_public_route(&mut self, route: routes::PublicRouteReg) {
self.add(route);
}
fn add_nav(&mut self, item: nav::NavItem) {
self.add(item);
}
fn add_resource_hook(&mut self, hook: ResourceHook) {
self.add(hook);
}
fn add_persister(&mut self, persister: persist::PersisterReg) {
self.add(persister);
}
fn add_field_type(&mut self, field_type: field::FieldTypeReg) {
self.add(field_type);
}
}
#[derive(Clone)]
pub struct ResourceHook {
pub target: String,
apply: Arc<dyn Fn(Resource) -> Resource + Send + Sync>,
}
impl ResourceHook {
pub fn new(
target: impl Into<String>,
apply: impl Fn(Resource) -> Resource + Send + Sync + 'static,
) -> Self {
Self {
target: target.into(),
apply: Arc::new(apply),
}
}
pub fn apply(&self, resource: Resource) -> Resource {
(self.apply)(resource)
}
}
impl std::fmt::Debug for ResourceHook {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ResourceHook({})", self.target)
}
}
pub(crate) const SESSION_COOKIE: &str = "laterite_session";
const REMEMBER_COOKIE: &str = "laterite_remember";
pub(crate) const ICON_SPRITE: &str = "icons.svg";
#[derive(Clone)]
pub(crate) struct AdminState {
auth: AuthService,
db: Db,
nav: Arc<nav::NavTree>,
settings: Arc<Vec<settings::SettingsItem>>,
permissions: Arc<Vec<Permission>>,
admin_path: Arc<str>,
cookie_path: Arc<str>,
storage_dir: Arc<str>,
signing_key: Arc<Vec<u8>>,
secure_cookie: bool,
trusted_proxies: Arc<Vec<ipnet::IpNet>>,
icons: Arc<laterite_core::icons::IconSet>,
sprite_url: Arc<str>,
origin: Arc<str>,
timezone: Tz,
default_locale: Arc<str>,
catalogs: Arc<CatalogStore>,
app_name: String,
brand_cache: Arc<RwLock<Option<String>>>,
asset_urls: Arc<AssetUrls>,
field_types: Arc<field::FieldRegistry>,
column_types: Arc<list::ColumnRegistry>,
plugin_defined: Arc<laterite_core::Registry>,
assets: Arc<AssetRegistry>,
pickers: Arc<picker::PickerRegistry>,
palette: Arc<Vec<palette::RecordSource>>,
overrides: Arc<dyn field::OverrideResolver>,
}
impl AdminState {
#[cfg(test)]
pub(crate) fn new(auth: AuthService, db: Db) -> Self {
Self {
auth,
db,
nav: Arc::new(nav::NavTree::default()),
settings: Arc::new(Vec::new()),
permissions: Arc::new(builtin_permissions()),
trusted_proxies: Arc::new(Vec::new()),
icons: Arc::new(laterite_core::icons::IconSet::new()),
sprite_url: Arc::from(""),
admin_path: Arc::from("/admin"),
cookie_path: Arc::from("/admin"),
storage_dir: Arc::from("storage"),
signing_key: Arc::new(random_key()),
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()),
plugin_defined: Arc::new(laterite_core::Registry::new()),
assets: Arc::new(builtin_assets()),
asset_urls: Arc::new(asset_urls(&builtin_assets())),
pickers: Arc::new(picker::PickerRegistry::new()),
palette: Arc::new(Vec::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 trusted_proxies: Vec<String>,
pub timezone: String,
pub locale: String,
pub app_name: String,
pub path: String,
pub origin: String,
pub storage_dir: String,
pub secret: Vec<u8>,
}
impl Default for AdminConfig {
fn default() -> Self {
Self {
secure_cookie: false,
trusted_proxies: Vec::new(),
timezone: "UTC".to_string(),
locale: "en".to_string(),
app_name: "Laterite".to_string(),
path: "/admin".to_string(),
origin: String::new(),
storage_dir: "storage".to_string(),
secret: random_key(),
}
}
}
pub(crate) fn random_key() -> Vec<u8> {
use rand::RngCore;
let mut key = vec![0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
key
}
#[derive(Clone)]
pub struct Shell {
pub(crate) base: String,
brand: String,
nav: Vec<NavView>,
full_name: String,
initial: String,
tz: Tz,
i18n: Translator,
sidebar: Vec<settings::CategoryView>,
crumbs: Vec<CrumbView>,
pub(crate) icon_set: Arc<laterite_core::icons::IconSet>,
pub(crate) sprite_url: Arc<str>,
site_url: String,
asset_urls: Arc<AssetUrls>,
pub(crate) csrf_token: String,
pub(crate) flash: Vec<FlashLine>,
pub(crate) assets: Vec<PageAsset>,
}
#[derive(Template)]
#[template(path = "page.html")]
struct PageTemplate {
title: String,
back: Option<(String, String)>,
actions: Option<html::Markup>,
body: html::Markup,
shell: Shell,
}
pub struct PageHead {
title: Text,
back: Option<(Text, String)>,
actions: Option<html::Markup>,
}
impl PageHead {
pub fn new(title: impl Into<Text>) -> Self {
Self {
title: title.into(),
back: None,
actions: None,
}
}
pub fn back(mut self, label: impl Into<Text>, href: impl Into<String>) -> Self {
self.back = Some((label.into(), href.into()));
self
}
pub fn actions(mut self, actions: html::Markup) -> Self {
self.actions = Some(actions);
self
}
}
#[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,
pub(crate) sticky: bool,
}
impl Shell {
#[allow(clippy::too_many_arguments)]
fn new(
base: &str,
brand: String,
nav: &[nav::NavNode],
user: &AuthenticatedUser,
default_tz: Tz,
sidebar: Vec<settings::CategoryView>,
active_nav: Option<&str>,
csrf_token: String,
flash: Vec<session::Flash>,
i18n: Translator,
asset_urls: Arc<AssetUrls>,
site_url: String,
icon_set: Arc<laterite_core::icons::IconSet>,
sprite_url: Arc<str>,
) -> 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 {
sticky: f.sticky,
level: f.level,
text: i18n.t(&f.text),
})
.collect();
let nav: Vec<NavView> = nav
.iter()
.map(|n| NavView {
key: n.item.key.clone(),
label: i18n.t(&n.item.label),
path: nav::tab_path(n).unwrap_or_default().to_string(),
active: active_nav == Some(n.item.key.as_str()),
icon: icons::svg(&icon_set, &sprite_url, n.item.icon.as_deref()),
})
.collect();
let mut crumbs: Vec<CrumbView> = nav
.iter()
.filter(|n: &&NavView| n.active)
.map(|n| CrumbView {
label: n.label.clone(),
href: n.path.clone(),
})
.collect();
if let Some(entry) = sidebar
.iter()
.flat_map(|g| g.items.iter())
.find(|i| i.active)
{
crumbs.push(CrumbView {
label: entry.label.clone(),
href: entry.path.clone(),
});
}
Shell {
asset_urls,
icon_set,
sprite_url,
site_url,
base: base.to_string(),
brand,
nav,
crumbs,
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 site_url(&self) -> &str {
&self.site_url
}
pub(crate) fn sidebar_anchored(&self) -> bool {
self.nav.iter().any(|n| n.active)
}
pub fn icon(&self, name: &str) -> String {
icons::svg(&self.icon_set, &self.sprite_url, Some(name))
}
pub(crate) fn asset(&self, key: &str) -> String {
match self.asset_urls.get(key) {
Some(path) => format!("{}/assets/{path}", self.base),
None => {
debug_assert!(false, "asset key `{key}` is not registered");
format!("{}/assets/{key}", self.base)
}
}
}
pub fn t(&self, source: &str) -> String {
self.i18n.t(&Text::dynamic(source))
}
pub fn tt(&self, text: &Text) -> String {
self.i18n.t(text)
}
pub fn i18n(&self) -> &Translator {
&self.i18n
}
pub fn locale(&self) -> &str {
self.i18n.locale()
}
pub fn base(&self) -> &str {
&self.base
}
pub fn csrf_token(&self) -> &str {
&self.csrf_token
}
pub fn datetime(&self, utc: &str) -> String {
match chrono::DateTime::parse_from_rfc3339(utc) {
Ok(at) => at
.with_timezone(&self.tz)
.format("%Y-%m-%d %H:%M")
.to_string(),
Err(_) => utc.to_string(),
}
}
pub fn page(&self, title: &Text, body: html::Markup) -> Response {
self.page_with(PageHead::new(title.clone()), body)
}
pub fn page_with(&self, head: PageHead, body: html::Markup) -> Response {
render(PageTemplate {
title: self.tt(&head.title),
back: head.back.map(|(label, href)| (self.tt(&label), href)),
actions: head.actions,
body,
shell: self.clone(),
})
}
pub 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 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 {
asset_urls: Arc::new(asset_urls(&builtin_assets())),
icon_set: Arc::new(laterite_core::icons::IconSet::new()),
sprite_url: Arc::from("/admin/assets/icons.svg"),
site_url: "http://localhost".to_string(),
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(),
crumbs: 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>) {
if path == format!("{admin_path}/settings") {
return (true, None);
}
let own = visible
.iter()
.filter(|i| i.link.is_none())
.find(|i| path == format!("{admin_path}{}", i.route()))
.map(|i| i.code.clone());
if own.is_some() {
return (true, own);
}
let linked = 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());
(linked.is_some(), linked)
}
#[allow(clippy::too_many_arguments)]
fn resolve_nav_context(
nav: &nav::NavTree,
items: &[settings::SettingsItem],
admin_path: &str,
perms: &PermissionSet,
own_order: Option<&str>,
path: &str,
tr: &Translator,
icons: icons::Icons<'_>,
) -> (
Vec<nav::NavNode>,
Vec<settings::CategoryView>,
Option<String>,
) {
let mut visible_nav = nav.visible(&|code| perms.allows(code));
if let Some(order) = own_order {
nav::apply_order(&mut visible_nav, order);
}
let visible: Vec<settings::SettingsItem> = visible_settings(items, perms)
.into_iter()
.filter(|i| !i.per_operator)
.collect();
let (in_context, active) = settings_context(&visible, admin_path, path);
if in_context {
let sidebar = settings::sidebar_groups(&visible, admin_path, active.as_deref(), tr, icons);
return (visible_nav, sidebar, Some("settings".to_string()));
}
let active_top = nav::active_top(&visible_nav, admin_path, path);
let sidebar = active_top
.map(|node| nav::section_sidebar(node, admin_path, path, tr, icons))
.unwrap_or_default();
let active_key = active_top.map(|node| node.item.key.clone());
(visible_nav, sidebar, active_key)
}
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)]
#[non_exhaustive]
pub struct Resource {
pub base_path: String,
pub nav_label: Text,
pub list: list::ListConfig,
pub form: Option<form::FormConfig>,
pub permission: Option<String>,
pub import: Option<ImportConfig>,
pub palette: Option<String>,
pub relations: Vec<relation::RelationConfig>,
pub menu: nav::Placement,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ImportConfig {
#[serde(default)]
pub permission: Option<String>,
}
impl ImportConfig {
pub fn new() -> Self {
Self::default()
}
pub fn permission(mut self, code: impl Into<String>) -> Self {
self.permission = Some(code.into());
self
}
}
#[derive(Clone, Serialize)]
#[non_exhaustive]
pub struct Permission {
pub code: String,
pub label: Text,
pub group: Text,
pub default_roles: Vec<String>,
pub renamed_from: Vec<String>,
}
pub use laterite_auth::{ROLE_ADMIN, ROLE_EDITOR};
impl Permission {
pub fn new(code: impl Into<String>, label: impl Into<Text>, group: impl Into<Text>) -> Self {
Self {
code: code.into(),
label: label.into(),
group: group.into(),
default_roles: Vec::new(),
renamed_from: Vec::new(),
}
}
pub fn renamed_from<I, S>(mut self, codes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.renamed_from = codes.into_iter().map(Into::into).collect();
self
}
pub fn roles<I, S>(mut self, roles: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.default_roles = roles.into_iter().map(Into::into).collect();
self
}
}
pub fn permission_renames(contributed: &[Permission]) -> Vec<(String, String)> {
let permissions: Vec<Permission> = builtin_permissions()
.into_iter()
.chain(contributed.iter().cloned())
.collect();
let mut out: Vec<(String, String)> = Vec::new();
for permission in &permissions {
for old in &permission.renamed_from {
if let Some((_, taken)) = out.iter().find(|(o, _)| o == old) {
panic!(
"permissions `{taken}` and `{}` both claim the old code `{old}`",
permission.code
);
}
if permissions.iter().any(|p| &p.code == old) {
panic!(
"permission `{}` claims `{old}` as an old code, but `{old}` is still registered",
permission.code
);
}
out.push((old.clone(), permission.code.clone()));
}
}
out
}
pub fn system_roles(contributed: &[Permission]) -> Vec<laterite_auth::store::SystemRole<'static>> {
let permissions: Vec<Permission> = builtin_permissions()
.into_iter()
.chain(contributed.iter().cloned())
.collect();
let held = |role: &str| -> Vec<String> {
permissions
.iter()
.filter(|p| {
if p.default_roles.is_empty() {
role == ROLE_ADMIN
} else {
p.default_roles.iter().any(|r| r == role)
}
})
.map(|p| p.code.clone())
.collect()
};
vec![
laterite_auth::store::SystemRole {
code: ROLE_ADMIN,
name: "Administrator",
description: "Administers the panel itself: operators, roles and settings.",
permissions: held(ROLE_ADMIN),
},
laterite_auth::store::SystemRole {
code: ROLE_EDITOR,
name: "Editor",
description: "Works with the content the application declares.",
permissions: held(ROLE_EDITOR),
},
]
}
impl Resource {
pub fn new(
base_path: impl Into<String>,
nav_label: impl Into<Text>,
list: list::ListConfig,
) -> Self {
Self {
base_path: base_path.into(),
nav_label: nav_label.into(),
list,
form: None,
permission: None,
import: None,
palette: None,
relations: Vec::new(),
menu: nav::Placement::default(),
}
}
pub fn palette(mut self, source: impl Into<String>) -> Self {
self.palette = Some(source.into());
self
}
pub fn menu(mut self, placement: nav::Placement) -> Self {
self.menu = placement;
self
}
pub fn relation(mut self, relation: relation::RelationConfig) -> Self {
self.relations.push(relation);
self
}
pub fn import(mut self, import: ImportConfig) -> Self {
self.import = Some(import);
self
}
pub fn form(mut self, form: form::FormConfig) -> Self {
self.form = Some(form);
self
}
pub fn permission(mut self, permission: impl Into<String>) -> Self {
self.permission = Some(permission.into());
self
}
}
struct SystemRoleGuard;
#[laterite_core::strata::async_trait]
impl laterite_core::ModelListener for SystemRoleGuard {
async fn before_delete(
&self,
cx: &mut laterite_core::SaveCx<'_>,
rec: &laterite_core::Record,
) -> Result<(), Text> {
let Some(id) = rec.id() else {
return Ok(());
};
let backend = cx.backend();
match laterite_auth::store::role_is_system_on(cx.conn(), backend, id).await {
Ok(true) => Err(t!(
"The framework maintains this role; it cannot be deleted."
)),
Err(_) => Err(t!(
"Could not check whether this role is maintained by the framework."
)),
Ok(false) => Ok(()),
}
}
}
fn builtin_permissions() -> Vec<Permission> {
let mut out = vec![
Permission::new("backend.manage_users", "Manage backend users", "Backend"),
Permission::new("backend.manage_roles", "Manage roles", "Backend"),
Permission::new("backend.manage_branding", "Manage branding", "Backend"),
Permission::new(plugins::MANAGE_PERMISSION, "Manage plugins", "Backend"),
Permission::new("backend.view_audit_log", "View the audit log", "Backend"),
Permission::new("backend.view_access_log", "View the access log", "Backend"),
Permission::new(
"backend.view_request_log",
"View the request log",
"Backend",
),
];
out.extend(medialib::permissions());
out
}
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(requestlog::RequestLogModule),
Box::new(plugins::PluginsModule),
Box::new(laterite_media::MediaModule),
Box::new(medialib::MediaLibraryModule),
Box::new(import::ImportModule),
]
}
#[derive(Default)]
pub struct Contributions {
pub resources: Vec<Resource>,
pub settings: Vec<settings::SettingsItem>,
pub permissions: Vec<Permission>,
pub picker_sources: Vec<picker::PickerSourceReg>,
pub persisters: Vec<persist::PersisterReg>,
pub listeners: Vec<laterite_core::ModelListenerReg>,
pub field_types: Vec<field::FieldTypeReg>,
pub column_types: Vec<list::ColumnTypeReg>,
pub icons: Vec<(laterite_core::ModuleId, laterite_core::icons::IconReg)>,
pub screens: Vec<routes::ScreenReg>,
pub public_routes: Vec<routes::PublicRouteReg>,
pub nav: Vec<nav::NavItem>,
pub resource_hooks: Vec<ResourceHook>,
pub plugin_defined: Arc<laterite_core::Registry>,
}
pub fn router(
auth: AuthService,
db: Db,
contributions: Contributions,
config: AdminConfig,
catalogs: Arc<CatalogStore>,
) -> Router {
let Contributions {
resources: app_resources,
settings: app_settings,
permissions: app_permissions,
picker_sources: app_picker_sources,
persisters: app_persisters,
listeners: app_listeners,
field_types: app_field_types,
column_types: app_column_types,
icons: app_icons,
screens: app_screens,
public_routes: app_public,
nav: app_nav,
resource_hooks,
plugin_defined,
} = contributions;
let mut app_screens = app_screens;
app_screens.push(medialib::screen());
let mut app_public = app_public;
app_public.push(medialib::serve_route());
let admin_path = normalize_path(&config.path);
let mut resources = builtin_resources();
let mut app_resources = app_resources;
for hook in resource_hooks {
let found = resources
.iter_mut()
.chain(app_resources.iter_mut())
.find(|r| r.base_path == hook.target);
match found {
Some(resource) => {
let changed = hook.apply(std::mem::replace(
resource,
Resource::new("", "", list::ListConfig::default()),
));
*resource = changed;
}
None => panic!(
"a resource hook targets `{}`, which no module declared",
hook.target
),
}
}
for resource in resources.iter_mut().chain(app_resources.iter_mut()) {
prefix_resource(&admin_path, resource);
}
for resource in app_resources.iter_mut() {
if let Some(import) = &resource.import {
let permission = import
.permission
.clone()
.or_else(|| resource.permission.clone())
.unwrap_or_else(|| {
panic!(
"admin resource `{}` declares `import` but no permission; importing \
writes records, so it must be gated",
resource.base_path
)
});
resource.list.import = Some(routes::feature_link(
&resource.base_path,
"import",
&permission,
));
}
}
let mut settings = builtin_settings();
settings.extend(app_settings);
let mut app_listeners = app_listeners;
app_listeners.insert(
0,
laterite_core::ModelListenerReg::for_entity("backend_roles", Arc::new(SystemRoleGuard)),
);
let mut permissions = builtin_permissions();
permissions.extend(app_permissions);
let permission_codes: std::collections::HashSet<String> =
permissions.iter().map(|p| p.code.clone()).collect();
let mut nav_items = vec![
nav::NavItem::new("dashboard", "Dashboard", "")
.icon("layout-dashboard")
.order(i32::MIN),
nav::NavItem::new("settings", "Settings", "/settings")
.icon("settings")
.order(i32::MAX),
];
nav_items.extend(app_nav);
for resource in &app_resources {
if resource.menu.hidden {
continue;
}
let unprefixed = resource
.base_path
.strip_prefix(admin_path.as_str())
.unwrap_or(&resource.base_path)
.to_string();
let mut item = nav::NavItem::new(
format!("resource:{unprefixed}"),
resource.nav_label.clone(),
unprefixed,
)
.order(resource.menu.order);
item.icon = resource.menu.icon.clone();
item.permission = resource.permission.clone();
item.under = resource.menu.under.clone();
item.after = resource.menu.after.clone();
item.before = resource.menu.before.clone();
nav_items.push(item);
}
for reg in &app_screens {
let Some(label) = ®.nav_label else {
continue;
};
if reg.menu.hidden {
continue;
}
let mut item = nav::NavItem::new(
format!("screen:{}", reg.base_path),
label.clone(),
reg.base_path.clone(),
)
.permission(reg.permission.clone())
.order(reg.menu.order);
item.icon = reg.menu.icon.clone();
item.under = reg.menu.under.clone();
item.after = reg.menu.after.clone();
item.before = reg.menu.before.clone();
nav_items.push(item);
}
let nav =
nav::NavTree::build(nav_items, &admin_path).unwrap_or_else(|e| panic!("admin menu: {e}"));
let palette_sources: Vec<palette::RecordSource> = app_resources
.iter()
.filter_map(|r| {
r.palette.as_ref().map(|source| palette::RecordSource {
label: r.nav_label.clone(),
source: source.clone(),
base_path: r.base_path.clone(),
permission: r.permission.clone(),
})
})
.collect();
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}`");
}
}
for source in &palette_sources {
if !pickers.contains_key(&source.source) {
panic!(
"admin resource `{}` names palette source `{}`, which no module registered",
source.base_path, source.source
);
}
}
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}`");
}
}
for reg in app_field_types {
let name = reg.name().to_string();
if !field::is_name(&name, true) {
panic!("invalid field type name `{name}`");
}
if field_types.insert(name.clone(), reg.field_type).is_some() {
panic!("duplicate field type `{name}`");
}
}
let mut icon_set = laterite_core::icons::IconSet::new();
for (owner, reg) in app_icons {
if let Err(e) = icon_set.add(owner.as_str(), ®) {
panic!("module `{owner}` contributed an invalid icon: {e}");
}
}
let mut assets = builtin_assets();
assets.insert(
ICON_SPRITE,
AdminAsset {
mime: "image/svg+xml",
bytes: std::borrow::Cow::Owned(icon_set.sprite().into_bytes()),
},
);
for item in &settings {
icons::require(
&icon_set,
&format!("settings item `{}`", item.code),
item.icon.as_deref(),
);
}
for node in nav
.top
.iter()
.flat_map(|n| std::iter::once(n).chain(n.children.iter()))
{
icons::require(
&icon_set,
&format!("menu entry `{}`", node.item.key),
node.item.icon.as_deref(),
);
}
let mut column_types = list::builtin_column_registry();
for reg in app_column_types {
let name = reg.name().to_string();
if !field::is_name(&name, true) {
panic!("invalid column type name `{name}`");
}
if column_types.insert(name.clone(), reg.column_type).is_some() {
panic!("duplicate column type `{name}`");
}
}
let settings = Arc::new(settings);
let asset_urls_arc = Arc::new(asset_urls(&assets));
let sprite_url: Arc<str> = Arc::from(
asset_urls_arc
.get(ICON_SPRITE)
.map(|p| format!("{admin_path}/assets/{p}"))
.unwrap_or_default()
.as_str(),
);
let icons_arc = Arc::new(icon_set);
let state = AdminState {
auth,
db,
nav: Arc::new(nav),
settings: settings.clone(),
permissions: Arc::new(permissions),
admin_path: Arc::from(admin_path.as_str()),
secure_cookie: config.secure_cookie,
trusted_proxies: Arc::new(clientip::parse_trusted(&config.trusted_proxies)),
icons: icons_arc.clone(),
sprite_url: sprite_url.clone(),
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(column_types),
plugin_defined,
storage_dir: Arc::from(config.storage_dir.trim_end_matches('/')),
signing_key: Arc::new(config.secret.clone()),
cookie_path: if app_public.iter().any(|r| r.is_site()) {
Arc::from("/")
} else {
Arc::from(admin_path.as_str())
},
assets: Arc::new(assets),
asset_urls: asset_urls_arc.clone(),
pickers,
palette: Arc::new(palette_sources),
overrides: Arc::new(field::NoOverrides),
};
let mut protected = Router::new().route(&admin_path, get(dashboard));
let mut forms: HashMap<String, Arc<form::PreparedForm>> = HashMap::new();
for resource in &resources {
let (mounted, prepared) = mount_resource(
resource,
&state.field_types,
&state.column_types,
&permission_codes,
&persisters,
&app_listeners,
);
protected = protected.merge(mounted);
if let Some(prepared) = prepared {
let key = resource
.base_path
.strip_prefix(admin_path.as_str())
.unwrap_or(&resource.base_path);
forms.insert(key.to_string(), prepared);
}
}
let forms: form::Forms = Arc::new(forms);
let private_disk: Arc<dyn laterite_media::StorageDriver> = state
.plugin_defined
.items::<laterite_media::DiskReg>()
.into_iter()
.find(|d| d.name == import::DISK)
.map(|d| d.driver.clone())
.unwrap_or_else(|| {
Arc::new(laterite_media::LocalDisk::new(format!(
"{}/{}",
config.storage_dir.trim_end_matches('/'),
import::DISK
)))
});
for resource in &resources {
let Some(link) = &resource.list.import else {
continue;
};
let key = resource
.base_path
.strip_prefix(admin_path.as_str())
.unwrap_or(&resource.base_path)
.to_string();
let prepared = forms.get(&key).unwrap_or_else(|| {
panic!(
"admin resource `{}` declares `import` but has no form to import through",
resource.base_path
)
});
let feature = import::ImportFeature {
disk: private_disk.clone(),
};
let base = link.href.clone();
let ctx = routes::RouteCtx::new(
state.db.clone(),
state.auth.clone(),
state.field_types.clone(),
state.overrides.clone(),
state.signing_key.clone(),
&state.storage_dir,
&base,
&admin_path,
&state.origin,
state.plugin_defined.clone(),
state.auth.events().clone(),
forms.clone(),
);
let info = routes::ResourceInfo::new(
key,
resource.base_path.clone(),
resource.nav_label.clone(),
link.permission.clone(),
form::FormHandle::new(state.db.clone(), prepared.clone()),
);
protected = protected.merge(guard_with_permission(
Router::new()
.nest_service(&base, routes::ResourceFeature::mount(&feature, &ctx, &info)),
&link.permission,
));
}
for reg in &app_screens {
let base = format!("{admin_path}{}", reg.base_path);
let ctx = routes::RouteCtx::new(
state.db.clone(),
state.auth.clone(),
state.field_types.clone(),
state.overrides.clone(),
state.signing_key.clone(),
&state.storage_dir,
&base,
&admin_path,
&state.origin,
state.plugin_defined.clone(),
state.auth.events().clone(),
forms.clone(),
);
protected = protected.merge(guard_with_permission(
Router::new().nest_service(&base, reg.screen.mount(&ctx)),
®.permission,
));
}
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/new"),
get(users::new_form).post(users::create),
)
.route(
&format!("{admin_path}/users/{{id}}/edit"),
get(users::edit_form).post(users::update),
)
.route(
&format!("{admin_path}/users/{{id}}/active"),
post(users::set_active),
)
.route(
&format!("{admin_path}/users/{{id}}/password"),
post(users::reset_password),
)
.route(
&format!("{admin_path}/users/{{id}}/unlock"),
post(users::unlock),
),
"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}/preferences"),
get(preferences_form).post(preferences_update),
)
.route(
&format!("{admin_path}/preferences/password"),
post(password_update),
)
.route(
&format!("{admin_path}/preferences/menu"),
post(menu_order_update),
)
.route(&format!("{admin_path}/palette"), get(palette::palette))
.route(
&format!("{admin_path}/preferences/sessions/revoke"),
post(session_revoke),
)
.route(
&format!("{admin_path}/preferences/sessions/revoke-others"),
post(session_revoke_others),
)
.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));
for item in settings.iter().filter(|i| i.link.is_none()) {
let path = format!("{admin_path}{}", item.route());
let code = item.code.clone();
let edit_code = code.clone();
protected = protected.route(
&path,
get(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>| {
let code = edit_code.clone();
async move { settings_edit(state, shell, user, code).await }
},
)
.post(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
Form(data): Form<HashMap<String, String>>| {
let code = code.clone();
async move { settings_update(state, shell, user, session, code, data).await }
},
),
);
}
let app = 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))
.merge(mount_public(
&app_public,
&state.db,
&state.auth,
&state.field_types,
&state.overrides,
&state.signing_key,
&state.storage_dir,
&admin_path,
&state.origin,
&state.plugin_defined,
state.auth.events(),
&forms,
));
let app = match app_public.iter().find(|r| r.is_site()) {
Some(site) => {
let ctx = routes::RouteCtx::new(
state.db.clone(),
state.auth.clone(),
state.field_types.clone(),
state.overrides.clone(),
state.signing_key.clone(),
&state.storage_dir,
"/",
&admin_path,
&state.origin,
state.plugin_defined.clone(),
state.auth.events().clone(),
forms.clone(),
);
app.fallback_service(site.route.mount(&ctx))
}
None => app.fallback(not_found_fallback),
};
app
.layer(middleware::from_fn_with_state(
state.clone(),
enforce_origin,
))
.layer(middleware::from_fn_with_state(
state.clone(),
capture_client,
))
.layer(
tower_http::set_header::SetResponseHeaderLayer::if_not_present(
header::CACHE_CONTROL,
axum::http::HeaderValue::from_static(http_cache::PRIVATE_NO_STORE),
),
)
.layer(
tower_http::set_header::SetResponseHeaderLayer::if_not_present(
header::VARY,
axum::http::HeaderValue::from_static("HX-Request"),
),
)
.layer(tower_http::catch_panic::CatchPanicLayer::custom(
handle_panic,
))
.with_state(state)
}
async fn not_found_fallback(
State(state): State<AdminState>,
method: axum::http::Method,
uri: axum::http::Uri,
) -> AdminError {
requestlog::record(&state.db, method.as_str(), uri.path(), 404).await;
AdminError::NotFound
}
async fn not_found_plain() -> 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}")
}
}
#[allow(clippy::too_many_arguments)]
fn mount_public(
regs: &[routes::PublicRouteReg],
db: &Db,
auth: &AuthService,
field_types: &Arc<field::FieldRegistry>,
overrides: &Arc<dyn field::OverrideResolver>,
signing_key: &Arc<Vec<u8>>,
storage_dir: &str,
admin_path: &str,
origin: &str,
plugin_defined: &Arc<laterite_core::Registry>,
events: &laterite_core::Events,
forms: &form::Forms,
) -> Router<AdminState> {
let mut router = Router::new();
for reg in regs.iter().filter(|r| !r.is_site()) {
let ctx = routes::RouteCtx::new(
db.clone(),
auth.clone(),
field_types.clone(),
overrides.clone(),
signing_key.clone(),
storage_dir,
®.path,
admin_path,
origin,
plugin_defined.clone(),
events.clone(),
forms.clone(),
);
router = router.nest_service(®.path, reg.route.mount(&ctx).fallback(not_found_plain));
}
router
}
pub(crate) fn rebase_resource(base: &str, resource: &mut Resource) {
let old = resource.base_path.clone();
resource.base_path = base.to_string();
if let Some(edit_base) = &mut resource.list.edit_base {
if edit_base == &old {
*edit_base = base.to_string();
}
}
if let Some(form) = &mut resource.form {
if form.base_path == old {
form.base_path = base.to_string();
}
}
}
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 bytes: std::borrow::Cow<'static, [u8]>,
}
pub(crate) type AssetRegistry = HashMap<&'static str, AdminAsset>;
pub(crate) type AssetUrls = HashMap<&'static str, String>;
pub(crate) fn asset_urls(registry: &AssetRegistry) -> AssetUrls {
registry
.iter()
.map(|(&key, asset)| {
(
key,
http_cache::fingerprint(key, &http_cache::digest(&asset.bytes)),
)
})
.collect()
}
pub(crate) fn page_assets(
keys: &[&str],
base: &str,
registry: &AssetRegistry,
urls: &AssetUrls,
) -> 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), urls.get(key)) {
(Some(asset), Some(path)) => out.push(PageAsset {
url: format!("{base}/assets/{path}"),
css: asset.mime.contains("css"),
}),
_ => debug_assert!(false, "asset key `{key}` is not registered"),
}
}
out
}
pub(crate) fn builtin_assets() -> AssetRegistry {
let font = |bytes: &'static [u8]| AdminAsset {
mime: "font/woff2",
bytes: std::borrow::Cow::Borrowed(bytes),
};
HashMap::from([
(
"laterite.css",
AdminAsset {
mime: "text/css; charset=utf-8",
bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.css")),
},
),
(
"laterite.js",
AdminAsset {
mime: "text/javascript; charset=utf-8",
bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/laterite.js")),
},
),
(
"vendor/htmx.min.js",
AdminAsset {
mime: "text/javascript; charset=utf-8",
bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/vendor/htmx.min.js")),
},
),
(
"fields/ref-picker.js",
AdminAsset {
mime: "text/javascript; charset=utf-8",
bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/fields/ref-picker.js")),
},
),
(
"fields/ref-picker.css",
AdminAsset {
mime: "text/css; charset=utf-8",
bytes: std::borrow::Cow::Borrowed(include_bytes!(
"../assets/fields/ref-picker.css"
)),
},
),
(
"mark.svg",
AdminAsset {
mime: "image/svg+xml",
bytes: std::borrow::Cow::Borrowed(include_bytes!("../assets/mark.svg")),
},
),
(
"mark.png",
AdminAsset {
mime: "image/png",
bytes: std::borrow::Cow::Borrowed(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>,
headers: HeaderMap,
Path(path): Path<String>,
) -> Response {
let (key, fingerprinted) = http_cache::strip_fingerprint(&path);
let Some(asset) = state.assets.get(key.as_str()) else {
return not_found();
};
let policy = if fingerprinted {
http_cache::IMMUTABLE
} else {
http_cache::REVALIDATE
};
http_cache::conditional(&headers, asset.mime, policy, asset.bytes.to_vec())
}
fn mount_resource(
resource: &Resource,
field_types: &field::FieldRegistry,
column_types: &list::ColumnRegistry,
permission_codes: &std::collections::HashSet<String>,
persisters: &persist::PersisterRegistry,
listeners: &[laterite_core::ModelListenerReg],
) -> (Router<AdminState>, Option<Arc<form::PreparedForm>>) {
list::check(&resource.list, column_types, permission_codes)
.unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
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_path = base.clone();
let mut list_cfg = resource.list.clone();
if resource.list.columns.iter().any(|c| c.editable) {
let Some(form_cfg) = resource.form.clone() else {
panic!(
"admin resource `{}` has an editable column but no form to edit through",
resource.base_path
);
};
for column in resource.list.columns.iter().filter(|c| c.editable) {
if !form_cfg.fields.iter().any(|f| f.name == column.field) {
panic!(
"admin resource `{}`: column `{}` is editable but the form has no such field",
resource.base_path, column.field
);
}
}
let prepared = form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
.unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
list_cfg.editor = Some(list::Editor(Arc::new(prepared)));
}
let cell_cfg = list_cfg.clone();
let mut router = Router::new().route(
&base,
get(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Query(params): Query<list::ListParams>,
Query(raw): Query<std::collections::HashMap<String, String>>,
axum::extract::RawQuery(query_string): axum::extract::RawQuery,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
headers: axum::http::HeaderMap| {
let cfg = list_cfg.clone();
let path = list_path.clone();
async move {
list::handle_remembering(
&state,
&cfg,
&path,
params,
&raw,
&user,
shell,
&headers,
query_string.as_deref(),
Some(&session),
)
.await
}
},
),
);
if cell_cfg.editor.is_some() {
let (cfg, list_path) = (cell_cfg, base.clone());
router = router.route(
&format!("{base}/{{id}}/cell"),
post(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
Path(id): Path<String>,
headers: axum::http::HeaderMap,
Form(data): Form<HashMap<String, String>>| {
let (cfg, path) = (cfg.clone(), list_path.clone());
async move {
list::cell_handler(
&state, &cfg, &path, &id, &user, &session, shell, &headers, data,
)
.await
}
},
),
);
}
let (export_cfg, export_path) = (resource.list.clone(), resource.base_path.clone());
if resource.list.exportable {
router = router.route(
&format!("{base}/export"),
get(
move |state: State<AdminState>,
Query(raw): Query<std::collections::HashMap<String, String>>,
Extension(user): Extension<AuthenticatedUser>,
Extension(shell): Extension<Shell>,
Extension(session): Extension<session::SessionHandle>| {
let cfg = export_cfg.clone();
let path = export_path.clone();
async move {
export::handle(state, &cfg, &path, &raw, &user, &shell, &session).await
}
},
),
);
}
let (columns_cfg, columns_path) = (resource.list.clone(), resource.base_path.clone());
router = router.route(
&format!("{base}/columns"),
post(
move |State(state): State<AdminState>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
headers: axum::http::HeaderMap,
Form(pairs): Form<Vec<(String, String)>>| {
let cfg = columns_cfg.clone();
let path = columns_path.clone();
async move {
list::set_columns(&state, &cfg, &path, &user, &session, &headers, &pairs).await
}
},
),
);
if resource.list.deletable {
let persister: Arc<dyn persist::Persister> =
match resource.form.as_ref().and_then(|f| f.persist.as_ref()) {
Some(key) => persisters.get(key).cloned().unwrap_or_else(|| {
panic!(
"admin resource `{}`: unregistered persister `{key}`",
resource.base_path
)
}),
None => Arc::new(persist::DefaultPersister::from_list(&resource.list)),
};
let ctx = Arc::new(bulk::BulkContext {
base_path: resource.base_path.clone(),
entity: resource.list.entity.clone(),
persister,
listeners: listeners
.iter()
.filter(|reg| reg.matches(&resource.list.entity))
.map(|reg| reg.listener.clone())
.collect(),
});
router =
router.route(
&format!("{base}/delete"),
post(
move |state: State<AdminState>,
user: Extension<AuthenticatedUser>,
session: Extension<session::SessionHandle>,
headers: axum::http::HeaderMap,
form: Form<Vec<(String, String)>>| {
let ctx = ctx.clone();
async move {
bulk::delete_handler(state, user, session, ctx, headers, form).await
}
},
),
);
}
if let Some(source) = resource.list.tree.as_ref().and_then(|t| t.source.clone()) {
let tree = resource.list.tree.clone().unwrap_or_default();
let mut move_cfg = form::FormConfig::new(
resource.list.entity.clone(),
laterite_core::t!("Move to"),
base.clone(),
resource.list.id_field.clone(),
vec![form::FormField::reference(
&tree.parent_key,
laterite_core::t!("New parent"),
&source,
)
.help(laterite_core::t!("Leave empty for the top level."))],
);
if let Some(persist) = resource.form.as_ref().and_then(|f| f.persist.clone()) {
move_cfg = move_cfg.persist(persist);
}
move_cfg.done_path = Some(base.clone());
let move_form = Arc::new(
form::PreparedForm::prepare(move_cfg, field_types, persisters, listeners)
.unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path)),
);
let (cfg, list_path) = (resource.list.clone(), base.clone());
let (form_get, form_post) = (move_form.clone(), move_form);
let (cfg_post, path_post) = (cfg.clone(), list_path.clone());
router = router.route(
&format!("{base}/{{id}}/move"),
get(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Path(id): Path<String>| {
let (cfg, path, pf) = (cfg.clone(), list_path.clone(), form_get.clone());
async move { list::move_form(&state, &cfg, &pf, &path, &id, shell).await }
},
)
.post(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
Path(id): Path<String>,
headers: axum::http::HeaderMap,
Form(data): Form<HashMap<String, String>>| {
let (cfg, path, pf) = (cfg_post.clone(), path_post.clone(), form_post.clone());
async move {
list::move_record(
&state, &cfg, &pf, &path, &id, &user, &session, shell, &headers, data,
)
.await
}
},
),
);
}
if resource.list.reorder.is_some() {
let (cfg, list_path) = (resource.list.clone(), base.clone());
router = router.route(
&format!("{base}/reorder"),
post(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
headers: axum::http::HeaderMap,
Form(pairs): Form<Vec<(String, String)>>| {
let cfg = cfg.clone();
let path = list_path.clone();
async move {
list::reorder_handler(
&state, &cfg, &path, "lat-list", None, &user, &session, shell,
&headers, &pairs,
)
.await
}
},
),
);
}
if !resource.relations.is_empty() && resource.form.is_none() {
panic!(
"admin resource `{}` declares relations but has no form to show them on",
resource.base_path
);
}
let mut relations: Vec<Arc<relation::PreparedRelation>> = Vec::new();
for rel in &resource.relations {
let prepared = relation::PreparedRelation::prepare(
resource,
rel.clone(),
field_types,
column_types,
permission_codes,
persisters,
listeners,
)
.unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
relations.push(Arc::new(prepared));
}
let relations = Arc::new(relations);
let mut prepared_form = None;
if let Some(form_cfg) = resource.form.clone() {
let mut prepared =
form::PreparedForm::prepare(form_cfg, field_types, persisters, listeners)
.unwrap_or_else(|e| panic!("admin resource `{}`: {e}", resource.base_path));
prepared.stay = !relations.is_empty();
let prepared = Arc::new(prepared);
prepared_form = Some(prepared.clone());
let (new_pf, create_pf) = (prepared.clone(), prepared.clone());
let relations_for_new = relations.clone();
router = router.route(
&format!("{base}/new"),
get(
move |State(state): State<AdminState>, Extension(shell): Extension<Shell>| {
let pf = new_pf.clone();
let relations = relations_for_new.clone();
async move {
let pending = relations
.iter()
.map(|r| shell.tt(&r.config.title))
.collect();
form::new_form(&state, &pf, shell, pending)
}
},
)
.post(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
headers: axum::http::HeaderMap,
Form(data): Form<HashMap<String, String>>| {
let pf = create_pf.clone();
async move {
form::create(&state, &pf, data, shell, &user, &session, &headers).await
}
},
),
);
let (edit_pf, update_pf) = (prepared.clone(), prepared.clone());
let relations_for_edit = relations.clone();
router = router.route(
&format!("{base}/{{id}}/edit"),
get(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Path(id): Path<String>| {
let pf = edit_pf.clone();
let relations = relations_for_edit.clone();
async move {
let mut sections = Vec::new();
for rel in relations.iter() {
if let Some(section) = rel.section(&state, &id, &user, &shell).await {
sections.push(section);
}
}
form::edit_form(&state, &pf, id, shell, sections).await
}
},
)
.post(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
Path(id): Path<String>,
headers: axum::http::HeaderMap,
Form(data): Form<HashMap<String, String>>| {
let pf = update_pf.clone();
async move {
form::update(&state, &pf, id, data, shell, &user, &session, &headers).await
}
},
),
);
}
for rel in relations.iter() {
let name = rel.config.name.clone();
let mut routes: Router<AdminState> = Router::new();
let r = rel.clone();
routes =
routes.route(
&format!("{base}/{{id}}/{name}"),
get(
move |state: State<AdminState>,
shell: Extension<Shell>,
user: Extension<AuthenticatedUser>,
id: Path<String>,
params: Query<list::ListParams>,
raw: Query<HashMap<String, String>>,
headers: axum::http::HeaderMap| {
let r = r.clone();
async move {
relation::index(state, shell, user, id, params, raw, headers, r).await
}
},
),
);
let (r_new, r_create) = (rel.clone(), rel.clone());
routes = routes.route(
&format!("{base}/{{id}}/{name}/new"),
get(
move |state: State<AdminState>, shell: Extension<Shell>, id: Path<String>| {
let r = r_new.clone();
async move { relation::new_form(state, shell, id, r).await }
},
)
.post(
move |state: State<AdminState>,
shell: Extension<Shell>,
user: Extension<AuthenticatedUser>,
session: Extension<session::SessionHandle>,
id: Path<String>,
headers: axum::http::HeaderMap,
data: Form<HashMap<String, String>>| {
let r = r_create.clone();
async move {
relation::create(state, shell, user, session, id, headers, data, r).await
}
},
),
);
let (r_edit, r_update) = (rel.clone(), rel.clone());
routes = routes.route(
&format!("{base}/{{id}}/{name}/{{child}}/edit"),
get(
move |state: State<AdminState>,
shell: Extension<Shell>,
ids: Path<(String, String)>| {
let r = r_edit.clone();
async move { relation::edit_form(state, shell, ids, r).await }
},
)
.post(
move |state: State<AdminState>,
shell: Extension<Shell>,
user: Extension<AuthenticatedUser>,
session: Extension<session::SessionHandle>,
ids: Path<(String, String)>,
headers: axum::http::HeaderMap,
data: Form<HashMap<String, String>>| {
let r = r_update.clone();
async move {
relation::update(state, shell, user, session, ids, headers, data, r).await
}
},
),
);
let r_delete = rel.clone();
routes = routes.route(
&format!("{base}/{{id}}/{name}/delete"),
post(
move |state: State<AdminState>,
shell: Extension<Shell>,
user: Extension<AuthenticatedUser>,
session: Extension<session::SessionHandle>,
id: Path<String>,
headers: axum::http::HeaderMap,
pairs: Form<Vec<(String, String)>>| {
let r = r_delete.clone();
async move {
relation::delete(state, shell, user, session, id, headers, pairs, r).await
}
},
),
);
if rel.reorderable() {
let r_reorder = rel.clone();
routes = routes.route(
&format!("{base}/{{id}}/{name}/reorder"),
post(
move |state: State<AdminState>,
shell: Extension<Shell>,
user: Extension<AuthenticatedUser>,
session: Extension<session::SessionHandle>,
id: Path<String>,
headers: axum::http::HeaderMap,
pairs: Form<Vec<(String, String)>>| {
let r = r_reorder.clone();
async move {
relation::reorder(state, shell, user, session, id, headers, pairs, r)
.await
}
},
),
);
}
if let Some(permission) = &rel.config.permission {
routes = guard_with_permission(routes, permission);
}
router = router.merge(routes);
}
if let Some(permission) = &resource.permission {
router = guard_with_permission(router, permission);
}
(router, prepared_form)
}
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 client_ctx = request
.extensions()
.get::<RequestContext>()
.cloned()
.unwrap_or_default();
let mut ended: Option<laterite_auth::SessionEnd> = None;
let live = match jar.get(SESSION_COOKIE) {
Some(cookie) => {
let token = cookie.value().to_string();
match state.auth.resolve_session(&token).await {
Ok(resolved) => Some((token, resolved)),
Err(_) => {
ended = state.auth.session_end(&token).await.ok().flatten();
None
}
}
}
None => None,
};
let htmx = request.headers().contains_key("hx-request");
let (token, resolved, recalled) = match live {
Some((token, resolved)) => (token, resolved, None),
None if matches!(ended, Some(laterite_auth::SessionEnd::Revoked(_))) => {
return signed_out(&state, jar, &login, ended, htmx);
}
None => match recall(&state, &jar, &client_ctx).await {
Some((token, resolved, credential)) => (token, resolved, Some(credential)),
None => {
if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
if let Ok(Some(reason)) = state.auth.remember_end(cookie.value()).await {
ended = Some(laterite_auth::SessionEnd::Revoked(reason));
}
}
return signed_out(&state, jar, &login, ended, htmx);
}
},
};
let handle = session::SessionHandle::from_blob(resolved.data.as_deref());
let mut request = request;
let safe = session::is_safe_method(request.method());
let mut deferred: Option<session::CsrfPending> = None;
if !safe {
let (parts, body) = request.into_parts();
let (body, submitted) = if session::is_multipart(&parts.headers) {
let token = session::header_token(&parts.headers)
.or_else(|| session::query_token(parts.uri.query()));
if token.is_none() {
deferred = Some(session::CsrfPending::default());
}
(body, token)
} else {
let bytes = axum::body::to_bytes(body, MAX_FORM_BYTES)
.await
.unwrap_or_default();
let token = session::submitted_token(&parts.headers, &bytes);
(Body::from(bytes), token)
};
if deferred.is_none() && !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);
}
let user = resolved.identity;
let acting_user_id = user.user.id;
let path = request.uri().path().to_string();
let preferences = format!("{}/preferences", state.admin_path);
if !path.starts_with(&preferences)
&& path != format!("{}/logout", state.admin_path)
&& state
.auth
.must_change_password(user.user.id)
.await
.unwrap_or(false)
{
let to = format!("{preferences}#password");
let response = if htmx {
(
[("HX-Redirect", to.as_str())],
axum::http::StatusCode::NO_CONTENT,
)
.into_response()
} else {
Redirect::to(&to).into_response()
};
return with_recall_cookies(response, recalled, jar, token, &state);
}
let flash = if safe {
handle.take_flash()
} else {
Vec::new()
};
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 own_order =
laterite_auth::store::user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
.await
.unwrap_or_default();
let (visible_nav, sidebar, active_nav) = resolve_nav_context(
&state.nav,
&state.settings,
&state.admin_path,
&user.permissions,
own_order.as_deref(),
&path,
&i18n,
icons::Icons::new(&state.icons, &state.sprite_url),
);
let brand = state.brand().await;
let shell = Shell::new(
&state.admin_path,
brand,
&visible_nav,
&user,
state.timezone,
sidebar,
active_nav.as_deref(),
handle.csrf_token(),
flash,
i18n,
state.asset_urls.clone(),
state.origin.to_string(),
state.icons.clone(),
state.sprite_url.clone(),
);
request.extensions_mut().insert(user);
request.extensions_mut().insert(shell);
request.extensions_mut().insert(handle.clone());
if let Some(pending) = &deferred {
request.extensions_mut().insert(pending.clone());
}
let mut response = next.run(request).await;
if let Some(pending) = &deferred {
if !pending.was_satisfied() {
tracing::error!(
user_id = acting_user_id,
"a multipart route returned without verifying its request token"
);
return error::csrf_rejected();
}
}
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");
}
}
with_recall_cookies(response, recalled, jar, token, &state)
}
fn with_recall_cookies(
response: Response,
recalled: Option<laterite_auth::RememberCredential>,
jar: CookieJar,
token: String,
state: &AdminState,
) -> Response {
match recalled {
Some(credential) => {
let jar = jar
.add(session_cookie(
token,
&state.cookie_path,
state.secure_cookie,
))
.add(remember_cookie(
credential,
&state.cookie_path,
state.secure_cookie,
));
(jar, response).into_response()
}
None => response,
}
}
async fn recall(
state: &AdminState,
jar: &CookieJar,
ctx: &RequestContext,
) -> Option<(
String,
laterite_auth::ResolvedSession,
laterite_auth::RememberCredential,
)> {
let presented = jar.get(REMEMBER_COOKIE)?.value().to_string();
let recalled = state.auth.consume_remember(&presented, ctx).await.ok()?;
let token = recalled.session.token.clone();
let resolved = state.auth.resolve_session(&token).await.ok()?;
Some((token, resolved, recalled.remember))
}
const MAX_FORM_BYTES: usize = 1024 * 1024;
async fn capture_client(
State(state): State<AdminState>,
mut request: Request,
next: Next,
) -> Response {
let peer = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|info| info.0);
let headers = request.headers();
let ctx = RequestContext {
ip_address: clientip::client_ip(peer, headers, &state.trusted_proxies),
user_agent: clientip::user_agent(headers),
};
request.extensions_mut().insert(ctx);
next.run(request).await
}
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
}
#[derive(Deserialize)]
struct LoginQuery {
ended: Option<String>,
}
async fn login_form(
State(state): State<AdminState>,
headers: HeaderMap,
Query(query): Query<LoginQuery>,
) -> Response {
match state.auth.has_any_operator().await {
Ok(false) => Redirect::to(&format!("{}/setup", state.admin_path)).into_response(),
Ok(true) => {
let i18n = pre_auth_translator(&state, &headers);
let notice = query
.ended
.as_deref()
.and_then(end_notice)
.map(|text| i18n.t(&text));
render(LoginTemplate {
base: state.admin_path.to_string(),
asset_urls: state.asset_urls.clone(),
brand: state.brand().await,
error: None,
notice,
i18n,
})
}
Err(_) => render_error(),
}
}
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
#[serde(default)]
remember: Option<String>,
}
async fn login_submit(
State(state): State<AdminState>,
jar: CookieJar,
headers: HeaderMap,
Extension(ctx): Extension<RequestContext>,
Form(form): Form<LoginForm>,
) -> Response {
match state
.auth
.authenticate(&form.username, &form.password, &ctx)
.await
{
Ok(session) => {
let wants_remember = form.remember.is_some();
let user_id = session.user_id;
let mut jar = jar.add(session_cookie(
session.token,
&state.cookie_path,
state.secure_cookie,
));
if wants_remember {
match state.auth.issue_remember(user_id).await {
Ok(credential) => {
jar = jar.add(remember_cookie(
credential,
&state.cookie_path,
state.secure_cookie,
));
}
Err(e) => {
tracing::error!(error = %e, "issuing a stay-signed-in credential failed")
}
}
}
(jar, Redirect::to(&state.admin_path)).into_response()
}
Err(_) => {
let i18n = pre_auth_translator(&state, &headers);
render(LoginTemplate {
base: state.admin_path.to_string(),
asset_urls: state.asset_urls.clone(),
brand: state.brand().await,
error: Some(i18n.t(&t!("Invalid username or password."))),
notice: None,
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()
}
fn remember_cookie(
credential: laterite_auth::RememberCredential,
admin_path: &str,
secure: bool,
) -> Cookie<'static> {
let seconds = (credential.expires_at - chrono::Utc::now())
.num_seconds()
.max(0);
Cookie::build((REMEMBER_COOKIE, credential.cookie))
.path(admin_path.to_string())
.http_only(true)
.secure(secure)
.same_site(SameSite::Lax)
.max_age(cookie::time::Duration::seconds(seconds))
.build()
}
fn signed_out(
state: &AdminState,
jar: CookieJar,
login: &str,
ended: Option<laterite_auth::SessionEnd>,
htmx: bool,
) -> Response {
let to = match ended.map(laterite_auth::SessionEnd::code) {
Some(code) => format!("{login}?ended={code}"),
None => login.to_string(),
};
let jar = jar
.remove(
Cookie::build((SESSION_COOKIE, ""))
.path(state.cookie_path.to_string())
.build(),
)
.remove(remember_removal(&state.cookie_path));
if htmx {
let mut response = (jar, axum::http::StatusCode::NO_CONTENT).into_response();
if let Ok(value) = axum::http::HeaderValue::from_str(&to) {
response.headers_mut().insert("hx-redirect", value);
}
return response;
}
(jar, Redirect::to(&to)).into_response()
}
fn end_notice(code: &str) -> Option<Text> {
Some(match code {
"password_changed" => {
t!("Your password was changed, so you were signed out. Sign in with the new one.")
}
"deactivated" => t!("This account was deactivated."),
"signed_out_elsewhere" => t!("You were signed out from another device."),
"expired" => t!("Your session ended after a period of inactivity. Sign in again."),
_ => return None,
})
}
fn remember_removal(admin_path: &str) -> Cookie<'static> {
Cookie::build((REMEMBER_COOKIE, ""))
.path(admin_path.to_string())
.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),
state.asset_urls.clone(),
state.auth.password_policy().min_length,
)),
Err(_) => render_error(),
}
}
async fn setup_submit(
State(state): State<AdminState>,
jar: CookieJar,
headers: HeaderMap,
client: Option<Extension<RequestContext>>,
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),
state.asset_urls.clone(),
state.auth.password_policy().min_length,
));
}
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),
state.asset_urls.clone(),
state.auth.password_policy().min_length,
));
}
let min_length = state.auth.password_policy().min_length;
if form.password.chars().count() < min_length {
return render(setup_view(
&state.admin_path,
state.brand().await,
state.timezone,
Some(t!(
"A password needs at least {n} characters.",
n = min_length as i64
)),
pre_auth_translator(&state, &headers),
state.asset_urls.clone(),
state.auth.password_policy().min_length,
));
}
let new = NewOperator {
username,
email,
first_name,
last_name: (!last_name.is_empty()).then_some(last_name),
password: &form.password,
timezone: Some(tz),
};
let id = match state.auth.create_superuser(new).await {
Ok(id) => id,
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),
state.asset_urls.clone(),
state.auth.password_policy().min_length,
));
}
};
let actor = laterite_core::Actor::system("first-run setup");
let target = id.to_string();
let entry = laterite_auth::AuditEntry::new(&actor, "backend.user.create").target(
"backend_user",
&target,
Some(username),
);
if let Err(e) = state.auth.record_audit(entry).await {
tracing::error!(error = %e, "failed to write audit log entry");
}
match state
.auth
.authenticate(
username,
&form.password,
&client.map(|c| c.0).unwrap_or_default(),
)
.await
{
Ok(session) => {
let cookie = session_cookie(session.token, &state.cookie_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,
asset_urls: Arc<AssetUrls>,
min_password: usize,
) -> 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(),
asset_urls,
brand,
zones,
error: error.map(|e| i18n.t(&e)),
min_password: min_password as i64,
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;
}
if let Some(cookie) = jar.get(REMEMBER_COOKIE) {
let _ = state.auth.revoke_remember(cookie.value()).await;
}
let removal = Cookie::build((SESSION_COOKIE, ""))
.path(state.cookie_path.to_string())
.build();
(
jar.remove(removal)
.remove(remember_removal(&state.cookie_path)),
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: AdminState,
shell: Shell,
user: AuthenticatedUser,
code: 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, &user).await,
None => not_found(),
}
}
async fn settings_update(
state: AdminState,
shell: Shell,
user: AuthenticatedUser,
session: session::SessionHandle,
code: String,
data: 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>,
jar: CookieJar,
) -> Response {
let account = account_security(&state, &user, &shell, &jar).await;
let items: Vec<PreferenceLink> = visible_settings(&state.settings, &user.permissions)
.into_iter()
.filter(|i| i.per_operator)
.map(|i| PreferenceLink {
label: shell.tt(&i.label),
description: shell.tt(&i.description),
href: i.path(&state.admin_path),
})
.collect();
render(preferences_view(
&shell,
&user,
state.timezone,
&state.default_locale,
&offered_locales(&state.catalogs),
account,
state.auth.password_policy().min_length,
items,
))
}
struct PreferenceLink {
label: String,
description: String,
href: String,
}
#[derive(Deserialize)]
struct RevokeSessionForm {
id: String,
}
async fn session_revoke(
State(state): State<AdminState>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
Form(form): Form<RevokeSessionForm>,
) -> Response {
match state.auth.revoke_session(user.user.id, &form.id).await {
Ok(true) => session.push_flash(FlashLevel::Success, t!("That session was signed out.")),
Ok(false) => session.push_flash(FlashLevel::Info, t!("That session had already ended.")),
Err(e) => {
tracing::error!(error = %e, "revoking a session failed");
session.push_flash(
FlashLevel::Error,
t!("That session could not be signed out."),
);
}
}
Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
}
#[derive(Deserialize)]
struct MenuOrderForm {
#[serde(default)]
order: String,
}
async fn menu_order_update(
State(state): State<AdminState>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
headers: axum::http::HeaderMap,
Form(form): Form<MenuOrderForm>,
) -> Response {
let order: Vec<&str> = form
.order
.split(',')
.map(str::trim)
.filter(|k| !k.is_empty() && k.len() <= 200)
.take(100)
.collect();
let result = if order.is_empty() {
laterite_auth::store::clear_user_preference(&state.db, user.user.id, nav::ORDER_PREFERENCE)
.await
} else {
laterite_auth::store::set_user_preference(
&state.db,
user.user.id,
nav::ORDER_PREFERENCE,
&order.join(","),
)
.await
};
if let Err(e) = result {
tracing::error!(error = %e, "storing the menu order failed");
return render_error();
}
if form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
return axum::http::StatusCode::NO_CONTENT.into_response();
}
session.push_flash(
FlashLevel::Success,
if order.is_empty() {
t!("The menu is back in its default order.")
} else {
t!("Saved.")
},
);
Redirect::to(&format!("{}/preferences", state.admin_path)).into_response()
}
async fn session_revoke_others(
State(state): State<AdminState>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
jar: CookieJar,
) -> Response {
let token = jar
.get(SESSION_COOKIE)
.map(|c| c.value().to_string())
.unwrap_or_default();
match state.auth.sign_out_everywhere(user.user.id, &token).await {
Ok(_) => session.push_flash(
FlashLevel::Success,
t!("Every other session was signed out."),
),
Err(e) => {
tracing::error!(error = %e, "signing out other sessions failed");
session.push_flash(
FlashLevel::Error,
t!("Those sessions could not be signed out."),
);
}
}
(
jar.remove(remember_removal(&state.cookie_path)),
Redirect::to(&format!("{}/preferences", state.admin_path)),
)
.into_response()
}
#[derive(Deserialize)]
struct PasswordForm {
current_password: String,
new_password: String,
confirm_password: String,
}
async fn password_update(
State(state): State<AdminState>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
Extension(ctx): Extension<RequestContext>,
jar: CookieJar,
Form(form): Form<PasswordForm>,
) -> Response {
let back = format!("{}/preferences#password", state.admin_path);
let refuse = |message: Text| {
session.push_flash(FlashLevel::Error, message);
Redirect::to(&back).into_response()
};
if form.new_password != form.confirm_password {
return refuse(t!("The new passwords do not match."));
}
let min_length = state.auth.password_policy().min_length;
if form.new_password.chars().count() < min_length {
return refuse(t!(
"A password needs at least {n} characters.",
n = min_length as i64
));
}
let remembered_here = match jar.get(REMEMBER_COOKIE) {
Some(cookie) => {
state
.auth
.remember_holder(cookie.value())
.await
.ok()
.flatten()
== Some(user.user.id)
}
None => false,
};
let token = jar
.get(SESSION_COOKIE)
.map(|c| c.value().to_string())
.unwrap_or_default();
let changed = state
.auth
.change_own_password(
user.user.id,
&form.current_password,
&form.new_password,
&token,
&ctx,
)
.await;
match changed {
Ok(()) => {}
Err(laterite_auth::AuthError::InvalidCredentials) => {
return refuse(t!("Your current password is not correct."));
}
Err(laterite_auth::AuthError::TooManyAttempts) => {
return refuse(t!("Too many attempts. Try again later."));
}
Err(e) => {
tracing::error!(error = %e, "changing a password failed");
return refuse(t!("The password could not be changed."));
}
}
session.push_flash_sticky(
FlashLevel::Success,
t!("Password changed. Every other device was signed out."),
);
let jar = if remembered_here {
match state.auth.issue_remember(user.user.id).await {
Ok(credential) => jar.add(remember_cookie(
credential,
&state.cookie_path,
state.secure_cookie,
)),
Err(e) => {
tracing::error!(error = %e, "reissuing a stay-signed-in credential failed");
jar.remove(remember_removal(&state.cookie_path))
}
}
} else if jar.get(REMEMBER_COOKIE).is_some() {
jar.remove(remember_removal(&state.cookie_path))
} else {
jar
};
(
jar,
Redirect::to(&format!("{}/preferences", state.admin_path)),
)
.into_response()
}
#[derive(Deserialize)]
struct PreferencesForm {
timezone: String,
#[serde(default)]
locale: String,
}
async fn preferences_update(
State(state): State<AdminState>,
Extension(user): Extension<AuthenticatedUser>,
Extension(session): Extension<session::SessionHandle>,
Form(form): Form<PreferencesForm>,
) -> Response {
let back = format!("{}/preferences", state.admin_path);
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 {
session.push_flash(FlashLevel::Error, t!("That is not a recognised timezone."));
return Redirect::to(&back).into_response();
};
let loc = form.locale.trim();
let loc_stored = if loc.is_empty() {
None
} else if offered.iter().any(|l| l == loc) {
Some(loc)
} else {
session.push_flash(FlashLevel::Error, t!("That is not a supported language."));
return Redirect::to(&back).into_response();
};
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(&back).into_response()
}
Err(_) => render_error(),
}
}
#[allow(clippy::too_many_arguments)]
fn preferences_view(
shell: &Shell,
user: &AuthenticatedUser,
default_tz: Tz,
default_locale: &str,
offered: &[String],
account: AccountSecurity,
min_password: usize,
items: Vec<PreferenceLink>,
) -> 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(),
sessions: account.sessions,
password_changed: account.password_changed,
must_change: account.must_change,
min_password: min_password as i64,
items,
}
}
struct AccountSecurity {
sessions: Vec<SessionRow>,
password_changed: Option<String>,
must_change: bool,
}
async fn account_security(
state: &AdminState,
user: &AuthenticatedUser,
shell: &Shell,
jar: &CookieJar,
) -> AccountSecurity {
let locale = list::date_locale(shell.locale());
let password_changed = match state.auth.password_changed_at(user.user.id).await {
Ok(at) => {
at.map(|dt| list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M"))
}
Err(e) => {
tracing::error!(error = %e, "reading the password change time failed");
None
}
};
AccountSecurity {
sessions: session_rows(state, user, shell, jar).await,
password_changed,
must_change: state
.auth
.must_change_password(user.user.id)
.await
.unwrap_or(false),
}
}
async fn session_rows(
state: &AdminState,
user: &AuthenticatedUser,
shell: &Shell,
jar: &CookieJar,
) -> Vec<SessionRow> {
let token = jar
.get(SESSION_COOKIE)
.map(|c| c.value().to_string())
.unwrap_or_default();
let locale = list::date_locale(shell.locale());
let stamp = |dt: chrono::DateTime<chrono::Utc>| {
list::format_ts(&dt.to_rfc3339(), shell.tz, locale, "%-d %b %Y, %H:%M")
};
match state.auth.list_sessions(user.user.id, &token).await {
Ok(sessions) => sessions
.into_iter()
.map(|s| SessionRow {
id: s.id,
started: stamp(s.created_at),
last_seen: stamp(s.last_seen_at),
expires: stamp(s.expires_at),
device: clientip::describe(s.user_agent.as_deref())
.or(s.user_agent)
.unwrap_or_else(|| "-".to_string()),
ip_address: s.ip_address.unwrap_or_default(),
current: s.current,
})
.collect(),
Err(e) => {
tracing::error!(error = %e, "listing sessions failed");
Vec::new()
}
}
}
fn builtin_resources() -> Vec<Resource> {
vec![
Resource {
base_path: "/users".to_string(),
nav_label: "Administrators".into(),
list: backend_users_list_config(),
form: None,
permission: Some("backend.manage_users".to_string()),
import: None,
palette: None,
relations: Vec::new(),
menu: nav::Placement::default(),
},
Resource {
base_path: "/roles".to_string(),
nav_label: "Roles".into(),
list: roles_list_config(),
form: None,
permission: Some("backend.manage_roles".to_string()),
import: None,
palette: None,
relations: Vec::new(),
menu: nav::Placement::default(),
},
resource!("descriptors/audit-log.yaml"),
resource!("descriptors/access-log.yaml"),
resource!("descriptors/request-log.yaml"),
]
}
fn builtin_settings() -> Vec<settings::SettingsItem> {
vec![
settings::SettingsItem::new("backend.administrators", "Administrators", Vec::new())
.description("Manage backend administrator accounts.")
.category("Users")
.order(10)
.icon("users")
.permission("backend.manage_users")
.link("/users"),
settings::SettingsItem::new("backend.roles", "Roles", Vec::new())
.description("Manage roles and their permissions.")
.category("Users")
.order(20)
.icon("shield")
.permission("backend.manage_roles")
.link("/roles"),
settings::brand::settings_item(),
settings::SettingsItem::new("backend.plugins", "Plugins", Vec::new())
.description("Enable or disable installed plugins.")
.category("System")
.order(10)
.icon("plug")
.permission(plugins::MANAGE_PERMISSION)
.link("/plugins"),
settings::SettingsItem::new("backend.audit_log", "Audit Log", Vec::new())
.description("Review the record of administrative changes.")
.category("System")
.order(20)
.icon("history")
.permission("backend.view_audit_log")
.link("/audit-log"),
settings::SettingsItem::new("backend.access_log", "Access Log", Vec::new())
.description("Review sign-ins, failures and lockouts.")
.category("System")
.order(30)
.icon("log-in")
.permission("backend.view_access_log")
.link("/access-log"),
settings::SettingsItem::new("backend.request_log", "Request Log", Vec::new())
.description("Review the URLs the panel could not serve.")
.category("System")
.order(31)
.icon("unlink")
.permission("backend.view_request_log")
.link("/request-log"),
]
}
fn backend_users_list_config() -> list::ListConfig {
list::ListConfig {
entity: "backend_users".to_string(),
title: "Administrators".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(),
creatable: true,
edit_base: Some("/users".to_string()),
filters: vec![
list::ListFilter::boolean("is_active", "Active"),
list::ListFilter::boolean("is_superuser", "Superuser"),
],
..Default::default()
}
}
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(),
edit_base: Some("/roles".to_string()),
creatable: true,
deletable: true,
..Default::default()
}
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
base: String,
asset_urls: Arc<AssetUrls>,
brand: String,
error: Option<String>,
notice: Option<String>,
i18n: Translator,
}
fn preauth_asset(urls: &AssetUrls, base: &str, key: &str) -> String {
match urls.get(key) {
Some(path) => format!("{base}/assets/{path}"),
None => {
debug_assert!(false, "asset key `{key}` is not registered");
format!("{base}/assets/{key}")
}
}
}
impl LoginTemplate {
fn asset(&self, key: &str) -> String {
preauth_asset(&self.asset_urls, &self.base, key)
}
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,
asset_urls: Arc<AssetUrls>,
brand: String,
zones: Vec<TzOption>,
error: Option<String>,
i18n: Translator,
min_password: i64,
}
impl SetupTemplate {
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)
}
fn asset(&self, key: &str) -> String {
preauth_asset(&self.asset_urls, &self.base, key)
}
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,
sessions: Vec<SessionRow>,
password_changed: Option<String>,
must_change: bool,
min_password: i64,
items: Vec<PreferenceLink>,
}
struct SessionRow {
id: String,
started: String,
last_seen: String,
expires: String,
device: String,
ip_address: String,
current: bool,
}
struct TzOption {
name: String,
selected: bool,
}
struct LocaleOption {
code: String,
name: String,
selected: bool,
}
#[derive(Clone)]
struct CrumbView {
label: String,
href: String,
}
#[derive(Clone)]
struct NavView {
key: String,
label: String,
path: String,
active: bool,
icon: String,
}
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 {
enter: Default::default(),
confirm_leave: true,
focus: Default::default(),
entity: "widgets".to_string(),
title: "Widgets".into(),
base_path: "/widgets".to_string(),
id_field: "id".to_string(),
fields: vec![],
persist: None,
timestamps: false,
done_path: 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),
import: None,
palette: None,
relations: Vec::new(),
menu: nav::Placement::default(),
}
}
#[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(),
&list::builtin_column_registry(),
&std::collections::HashSet::new(),
&persist::PersisterRegistry::new(),
&[],
);
}
#[test]
fn the_access_log_is_read_from_its_descriptor_file() {
let access = builtin_resources()
.into_iter()
.find(|r| r.base_path == "/access-log")
.expect("the access log is registered");
assert_eq!(access.list.entity, "backend_access_log");
assert_eq!(
access.permission.as_deref(),
Some("backend.view_access_log")
);
assert!(access.form.is_none(), "append-only");
assert_eq!(
access
.list
.columns
.iter()
.map(|c| c.field.as_str())
.collect::<Vec<_>>(),
[
"created_at",
"username_attempted",
"event",
"ip_address",
"user_agent"
]
);
}
#[test]
fn the_audit_log_is_read_from_its_descriptor_file() {
let audit = builtin_resources()
.into_iter()
.find(|r| r.base_path == "/audit-log")
.expect("the audit log is registered");
assert_eq!(audit.list.entity, "backend_audit_log");
assert_eq!(audit.permission.as_deref(), Some("backend.view_audit_log"));
assert_eq!(
audit
.list
.columns
.iter()
.map(|c| c.field.as_str())
.collect::<Vec<_>>(),
[
"created_at",
"actor_username",
"action",
"target_type",
"target_id",
"target_label"
],
"in the order the file writes them"
);
assert_eq!(audit.list.columns[0].column_type, "datetime");
assert_eq!(audit.list.per_page, 50);
assert_eq!(audit.list.per_page_options, [25, 50, 100]);
assert!(audit.list.exportable, "evidence may leave the panel");
assert!(audit.form.is_none(), "append-only: no form");
assert!(!audit.list.deletable, "and nothing removes from it");
}
#[test]
#[should_panic(expected = "uses unregistered type `nope`")]
fn unregistered_column_type_aborts_boot() {
let mut resource = resource_with(None, None);
let mut score = list::ListColumn::new("score", "Score");
score.column_type = "nope".into();
resource.list.columns.push(score);
let _ = mount_resource(
&resource,
&field::builtin_registry(),
&list::builtin_column_registry(),
&std::collections::HashSet::new(),
&persist::PersisterRegistry::new(),
&[],
);
}
#[test]
fn read_only_resource_may_omit_permission() {
let resource = resource_with(None, None);
let _ = mount_resource(
&resource,
&field::builtin_registry(),
&list::builtin_column_registry(),
&std::collections::HashSet::new(),
&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",
bytes: std::borrow::Cow::Borrowed(b"//"),
},
);
reg.insert(
"b.css",
AdminAsset {
mime: "text/css; charset=utf-8",
bytes: std::borrow::Cow::Borrowed(b"body{}"),
},
);
let urls = asset_urls(®);
let assets = page_assets(&["a.js", "b.css", "a.js"], "/admin", ®, &urls);
assert_eq!(assets.len(), 2);
assert_eq!(
assets[0].url,
format!("/admin/assets/a.{}.js", http_cache::digest(b"//"))
);
assert!(!assets[0].css);
assert_eq!(
assets[1].url,
format!("/admin/assets/b.{}.css", http_cache::digest(b"body{}"))
);
assert!(assets[1].css);
assert_ne!(assets[0].url, assets[1].url);
}
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(nav::NavTree::default()),
settings: Arc::new(Vec::new()),
permissions: Arc::new(builtin_permissions()),
trusted_proxies: Arc::new(Vec::new()),
icons: Arc::new(laterite_core::icons::IconSet::new()),
sprite_url: Arc::from(""),
admin_path: Arc::from("/admin"),
cookie_path: Arc::from("/admin"),
storage_dir: Arc::from("storage"),
signing_key: Arc::new(random_key()),
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()),
plugin_defined: Arc::new(laterite_core::Registry::new()),
assets: Arc::new(builtin_assets()),
asset_urls: Arc::new(asset_urls(&builtin_assets())),
pickers: Arc::new(picker::PickerRegistry::new()),
palette: Arc::new(Vec::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(),
hint: None,
confirm_leave: true,
order: 1,
icon: None,
permission: permission.map(str::to_string),
link: None,
fields: Vec::new(),
route: None,
per_operator: false,
}
}
#[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 tree = nav::NavTree::default();
let sidebar = |path: &str| {
resolve_nav_context(
&tree,
&items,
"/admin",
&superuser,
None,
path,
&tr,
icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
)
.1
};
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/branding").as_deref(),
Some("/admin/settings/branding")
);
assert_eq!(active_path("/admin/settings/laterite.brand"), None);
assert_eq!(active_path("/admin/settings/laterite/brand"), None);
assert!(sidebar("/admin").is_empty());
}
}
#[cfg(test)]
mod session_cookie_tests {
use super::*;
#[test]
fn the_session_cookie_never_outlives_the_browser() {
let cookie = session_cookie("tok".into(), "/admin", false);
assert_eq!(cookie.max_age(), None);
assert!(cookie.http_only().unwrap_or(false));
assert_eq!(cookie.path(), Some("/admin"));
}
#[test]
fn the_remember_cookie_carries_its_own_lifetime() {
let credential = laterite_auth::RememberCredential {
cookie: "sel:ver".into(),
expires_at: chrono::Utc::now() + chrono::Duration::days(14),
};
let cookie = remember_cookie(credential, "/admin", true);
let age = cookie.max_age().expect("a remember cookie must persist");
assert!((age.whole_seconds() - 14 * 24 * 60 * 60).abs() <= 1);
assert!(cookie.http_only().unwrap_or(false));
assert!(cookie.secure().unwrap_or(false));
}
#[test]
fn the_removal_matches_the_cookie_it_clears() {
let removal = remember_removal("/admin");
assert_eq!(removal.name(), REMEMBER_COOKIE);
assert_eq!(removal.path(), Some("/admin"));
}
}