pub mod form;
mod icons;
pub mod list;
mod roles;
pub mod settings;
mod sql;
mod users;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use askama::Template;
use axum::extract::{Path, Query, Request, State};
use axum::http::{header, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{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::Db;
use serde::Deserialize;
const SESSION_COOKIE: &str = "laterite_session";
#[derive(Clone)]
pub(crate) struct AdminState {
auth: AuthService,
db: Db,
nav: Arc<Vec<NavLink>>,
settings: Arc<Vec<settings::SettingsItem>>,
permissions: Arc<Vec<Permission>>,
secure_cookie: bool,
timezone: Tz,
app_name: String,
brand_cache: Arc<RwLock<Option<String>>>,
}
impl AdminState {
#[cfg(test)]
pub(crate) fn new(auth: AuthService, db: Db) -> Self {
Self {
auth,
db,
nav: Arc::new(Vec::new()),
settings: Arc::new(Vec::new()),
permissions: Arc::new(builtin_permissions()),
secure_cookie: false,
timezone: Tz::UTC,
app_name: "Laterite".to_string(),
brand_cache: Arc::new(RwLock::new(None)),
}
}
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)]
pub struct AdminConfig {
pub secure_cookie: bool,
pub timezone: String,
pub app_name: String,
}
impl Default for AdminConfig {
fn default() -> Self {
Self {
secure_cookie: false,
timezone: "UTC".to_string(),
app_name: "Laterite".to_string(),
}
}
}
#[derive(Clone)]
struct NavLink {
label: String,
path: String,
icon: Option<&'static str>,
}
#[derive(Clone)]
pub(crate) struct Shell {
brand: String,
nav: Vec<NavView>,
full_name: String,
initial: String,
tz: Tz,
sidebar: Vec<settings::CategoryView>,
}
impl Shell {
fn new(
brand: String,
nav: &[NavLink],
user: &AuthenticatedUser,
default_tz: Tz,
sidebar: Vec<settings::CategoryView>,
active_nav: Option<&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());
Shell {
brand,
nav: nav
.iter()
.map(|n| NavView {
label: n.label.clone(),
path: n.path.clone(),
active: active_nav == Some(n.path.as_str()),
icon: n.icon.map(|name| icons::svg(Some(name))).unwrap_or(""),
})
.collect(),
full_name,
initial,
tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
sidebar,
}
}
#[cfg(test)]
pub(crate) fn test() -> Self {
Shell {
brand: "Laterite".to_string(),
nav: Vec::new(),
full_name: "Test Operator".to_string(),
initial: "T".to_string(),
tz: Tz::UTC,
sidebar: Vec::new(),
}
}
}
fn settings_context(visible: &[settings::SettingsItem], path: &str) -> (bool, Option<String>) {
if path == "/admin/settings" {
(true, None)
} else if let Some(code) = path.strip_prefix("/admin/settings/") {
(true, Some(code.to_string()))
} else {
let active = visible
.iter()
.filter_map(|i| i.link.as_deref().map(|link| (link, &i.code)))
.filter(|(link, _)| path == *link || path.starts_with(&format!("{link}/")))
.max_by_key(|(link, _)| link.len())
.map(|(_, code)| code.clone());
(active.is_some(), active)
}
}
fn active_nav_path(nav: &[NavLink], in_settings_context: bool, path: &str) -> Option<String> {
if in_settings_context {
return Some("/admin/settings".to_string());
}
nav.iter()
.filter(|n| {
path == n.path || (n.path != "/admin" && path.starts_with(&format!("{}/", n.path)))
})
.max_by_key(|n| n.path.len())
.map(|n| n.path.clone())
}
fn resolve_nav_context(
nav: &[NavLink],
items: &[settings::SettingsItem],
perms: &PermissionSet,
path: &str,
) -> (Vec<settings::CategoryView>, Option<String>) {
let visible = visible_settings(items, perms);
let (in_context, active) = settings_context(&visible, path);
let sidebar = if in_context {
settings::sidebar_groups(&visible, active.as_deref())
} else {
Vec::new()
};
let active_nav = active_nav_path(nav, in_context, path);
(sidebar, active_nav)
}
fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
preference
.and_then(|name| name.parse::<Tz>().ok())
.unwrap_or(default_tz)
}
pub struct Resource {
pub base_path: String,
pub nav_label: String,
pub list: list::ListConfig,
pub form: Option<form::FormConfig>,
pub permission: Option<String>,
}
#[derive(Clone)]
pub struct Permission {
pub code: String,
pub label: String,
pub group: String,
}
fn builtin_permissions() -> Vec<Permission> {
vec![
Permission {
code: "backend.manage_users".to_string(),
label: "Manage backend users".to_string(),
group: "Backend".to_string(),
},
Permission {
code: "backend.manage_roles".to_string(),
label: "Manage roles".to_string(),
group: "Backend".to_string(),
},
Permission {
code: "backend.manage_branding".to_string(),
label: "Manage branding".to_string(),
group: "Backend".to_string(),
},
]
}
pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
vec![laterite_auth::migrations(), settings::migrations()]
}
pub fn router(
auth: AuthService,
db: Db,
app_resources: Vec<Resource>,
app_settings: Vec<settings::SettingsItem>,
app_permissions: Vec<Permission>,
config: AdminConfig,
) -> Router {
let mut resources = builtin_resources();
let mut settings = builtin_settings();
settings.extend(app_settings);
let mut permissions = builtin_permissions();
permissions.extend(app_permissions);
let mut nav = vec![NavLink {
label: "Dashboard".to_string(),
path: "/admin".to_string(),
icon: Some("layout-dashboard"),
}];
for resource in &app_resources {
nav.push(NavLink {
label: resource.nav_label.clone(),
path: resource.base_path.clone(),
icon: None,
});
}
nav.push(NavLink {
label: "Settings".to_string(),
path: "/admin/settings".to_string(),
icon: Some("settings"),
});
resources.extend(app_resources);
let app_name = if config.app_name.trim().is_empty() {
"Laterite".to_string()
} else {
config.app_name.clone()
};
let state = AdminState {
auth,
db,
nav: Arc::new(nav),
settings: Arc::new(settings),
permissions: Arc::new(permissions),
secure_cookie: config.secure_cookie,
timezone: config.timezone.parse().unwrap_or(Tz::UTC),
app_name,
brand_cache: Arc::new(RwLock::new(None)),
};
let mut protected = Router::new().route("/admin", get(dashboard));
for resource in &resources {
protected = protected.merge(mount_resource(resource));
}
protected = protected.merge(guard_with_permission(
Router::new()
.route("/admin/roles/new", get(roles::new_form).post(roles::create))
.route(
"/admin/roles/{id}/edit",
get(roles::edit_form).post(roles::update),
),
"backend.manage_roles",
));
protected = protected.merge(guard_with_permission(
Router::new().route(
"/admin/users/{id}/edit",
get(users::edit_form).post(users::update),
),
"backend.manage_users",
));
protected = protected
.route("/admin/settings", get(settings_index))
.route(
"/admin/settings/{code}",
get(settings_edit).post(settings_update),
)
.route(
"/admin/preferences",
get(preferences_form).post(preferences_update),
)
.route("/admin/logout", post(logout));
protected
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
.route("/admin/login", get(login_form).post(login_submit))
.route("/admin/setup", get(setup_form).post(setup_submit))
.route("/admin/assets/laterite.css", get(asset_css))
.route("/admin/assets/mark.svg", get(asset_mark))
.route("/admin/assets/mark.png", get(asset_mark_png))
.route("/admin/assets/fonts/{file}", get(asset_font))
.with_state(state)
}
async fn asset_mark() -> Response {
(
[(header::CONTENT_TYPE, "image/svg+xml")],
include_str!("../assets/mark.svg"),
)
.into_response()
}
async fn asset_mark_png() -> Response {
(
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
&include_bytes!("../assets/mark.png")[..],
)
.into_response()
}
async fn asset_css() -> Response {
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("../assets/laterite.css"),
)
.into_response()
}
async fn asset_font(Path(file): Path<String>) -> Response {
let bytes: &[u8] = match file.as_str() {
"space-grotesk-500.woff2" => &include_bytes!("../assets/fonts/space-grotesk-500.woff2")[..],
"space-grotesk-600.woff2" => &include_bytes!("../assets/fonts/space-grotesk-600.woff2")[..],
"space-grotesk-700.woff2" => &include_bytes!("../assets/fonts/space-grotesk-700.woff2")[..],
"ibm-plex-sans-400.woff2" => &include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")[..],
"ibm-plex-sans-600.woff2" => &include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")[..],
"ibm-plex-mono-400.woff2" => &include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")[..],
"ibm-plex-mono-600.woff2" => &include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")[..],
_ => return not_found(),
};
(
[
(header::CONTENT_TYPE, "font/woff2"),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
bytes,
)
.into_response()
}
fn mount_resource(resource: &Resource) -> Router<AdminState> {
let base = resource.base_path.clone();
let list_cfg = resource.list.clone();
let mut router = Router::new().route(
&base,
get(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Query(params): Query<list::ListParams>| {
let cfg = list_cfg.clone();
async move { list::handle(&state, &cfg, params, shell).await }
},
),
);
if let Some(form_cfg) = resource.form.clone() {
let (new_cfg, create_cfg) = (form_cfg.clone(), form_cfg.clone());
router = router.route(
&format!("{base}/new"),
get(move |Extension(shell): Extension<Shell>| {
let cfg = new_cfg.clone();
async move { form::new_form(&cfg, shell) }
})
.post(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Form(data): Form<HashMap<String, String>>| {
let cfg = create_cfg.clone();
async move { form::create(&state, &cfg, data, shell).await }
},
),
);
let (edit_cfg, update_cfg) = (form_cfg.clone(), form_cfg.clone());
router = router.route(
&format!("{base}/{{id}}/edit"),
get(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Path(id): Path<String>| {
let cfg = edit_cfg.clone();
async move { form::edit_form(&state, &cfg, id, shell).await }
},
)
.post(
move |State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Path(id): Path<String>,
Form(data): Form<HashMap<String, String>>| {
let cfg = update_cfg.clone();
async move { form::update(&state, &cfg, id, data, shell).await }
},
),
);
}
if let Some(permission) = &resource.permission {
router = guard_with_permission(router, permission);
}
router
}
fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
let needed: Arc<str> = Arc::from(permission);
router.route_layer(middleware::from_fn(
move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
let needed = needed.clone();
async move {
if user.allows(&needed) {
next.run(req).await
} else {
forbidden()
}
}
},
))
}
async fn require_auth(
State(state): State<AdminState>,
jar: CookieJar,
mut request: Request,
next: Next,
) -> Response {
let identity = match jar.get(SESSION_COOKIE) {
Some(cookie) => state.auth.verify_session(cookie.value()).await.ok(),
None => None,
};
match identity {
Some(user) => {
let path = request.uri().path().to_string();
let (sidebar, active_nav) =
resolve_nav_context(&state.nav, &state.settings, &user.permissions, &path);
let brand = state.brand().await;
let shell = Shell::new(
brand,
&state.nav,
&user,
state.timezone,
sidebar,
active_nav.as_deref(),
);
request.extensions_mut().insert(user);
request.extensions_mut().insert(shell);
next.run(request).await
}
None => Redirect::to("/admin/login").into_response(),
}
}
async fn login_form(State(state): State<AdminState>) -> Response {
match state.auth.has_any_operator().await {
Ok(false) => Redirect::to("/admin/setup").into_response(),
Ok(true) => render(LoginTemplate {
brand: state.brand().await,
error: None,
}),
Err(_) => render_error(),
}
}
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
async fn login_submit(
State(state): State<AdminState>,
jar: CookieJar,
Form(form): Form<LoginForm>,
) -> Response {
match state
.auth
.authenticate(&form.username, &form.password, &RequestContext::default())
.await
{
Ok(session) => {
let cookie = session_cookie(session.token, state.secure_cookie);
(jar.add(cookie), Redirect::to("/admin")).into_response()
}
Err(_) => render(LoginTemplate {
brand: state.brand().await,
error: Some("Invalid username or password.".to_string()),
}),
}
}
fn session_cookie(token: String, secure: bool) -> Cookie<'static> {
Cookie::build((SESSION_COOKIE, token))
.path("/admin")
.http_only(true)
.secure(secure)
.same_site(SameSite::Lax)
.build()
}
#[derive(Deserialize)]
struct SetupForm {
username: String,
first_name: String,
last_name: String,
email: String,
password: String,
timezone: String,
}
async fn setup_form(State(state): State<AdminState>) -> Response {
match state.auth.has_any_operator().await {
Ok(true) => Redirect::to("/admin/login").into_response(),
Ok(false) => render(setup_view(state.brand().await, state.timezone, None)),
Err(_) => render_error(),
}
}
async fn setup_submit(
State(state): State<AdminState>,
jar: CookieJar,
Form(form): Form<SetupForm>,
) -> Response {
match state.auth.has_any_operator().await {
Ok(true) => return Redirect::to("/admin/login").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.brand().await,
state.timezone,
Some("Username, first name, email, and password are all required."),
));
}
if tz.parse::<Tz>().is_err() {
return render(setup_view(
state.brand().await,
state.timezone,
Some("That is not a recognised timezone."),
));
}
let new = NewOperator {
username,
email,
first_name,
last_name: (!last_name.is_empty()).then_some(last_name),
password: &form.password,
timezone: Some(tz),
};
if state.auth.create_superuser(new).await.is_err() {
return render(setup_view(
state.brand().await,
state.timezone,
Some("Could not create the account. The username or email may already be taken."),
));
}
match state
.auth
.authenticate(username, &form.password, &RequestContext::default())
.await
{
Ok(session) => {
let cookie = session_cookie(session.token, state.secure_cookie);
(jar.add(cookie), Redirect::to("/admin")).into_response()
}
Err(_) => Redirect::to("/admin/login").into_response(),
}
}
fn setup_view(brand: String, default_tz: Tz, error: Option<&str>) -> SetupTemplate {
let default_name = default_tz.name();
let zones = TZ_VARIANTS
.iter()
.map(|tz| TzOption {
name: tz.name().to_string(),
selected: tz.name() == default_name,
})
.collect();
SetupTemplate {
brand,
zones,
error: error.map(|e| e.to_string()),
}
}
async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
if let Some(cookie) = jar.get(SESSION_COOKIE) {
let _ = state.auth.logout(cookie.value()).await;
}
let removal = Cookie::build((SESSION_COOKIE, "")).path("/admin").build();
(jar.remove(removal), Redirect::to("/admin/login")).into_response()
}
async fn dashboard(
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
) -> Response {
render(DashboardTemplate {
username: user.user.username,
shell,
})
}
fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
match &item.permission {
None => true,
Some(p) => perms.allows(p),
}
}
fn visible_settings(
items: &[settings::SettingsItem],
perms: &PermissionSet,
) -> Vec<settings::SettingsItem> {
items
.iter()
.filter(|item| operator_can_see(item, perms))
.cloned()
.collect()
}
async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
settings::index(shell)
}
async fn settings_edit(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Path(code): Path<String>,
) -> Response {
let items = visible_settings(&state.settings, &user.permissions);
match items
.iter()
.find(|item| item.code == code && item.link.is_none())
{
Some(item) => settings::edit_form(&state, item, shell).await,
None => not_found(),
}
}
async fn settings_update(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Path(code): Path<String>,
Form(data): Form<HashMap<String, String>>,
) -> Response {
let items = visible_settings(&state.settings, &user.permissions);
match items
.iter()
.find(|item| item.code == code && item.link.is_none())
{
Some(item) => settings::update(&state, item, data, shell).await,
None => not_found(),
}
}
#[derive(Deserialize)]
struct PreferencesQuery {
saved: Option<String>,
}
async fn preferences_form(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Query(query): Query<PreferencesQuery>,
) -> Response {
render(preferences_view(
&shell,
&user,
state.timezone,
query.saved.is_some(),
None,
))
}
#[derive(Deserialize)]
struct PreferencesForm {
timezone: String,
}
async fn preferences_update(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Form(form): Form<PreferencesForm>,
) -> Response {
let trimmed = form.timezone.trim();
let stored = if trimmed.is_empty() {
None
} else if trimmed.parse::<Tz>().is_ok() {
Some(trimmed)
} else {
return render(preferences_view(
&shell,
&user,
state.timezone,
false,
Some("That is not a recognised timezone."),
));
};
match state.auth.set_user_timezone(user.user.id, stored).await {
Ok(()) => Redirect::to("/admin/preferences?saved=1").into_response(),
Err(_) => render_error(),
}
}
fn preferences_view(
shell: &Shell,
user: &AuthenticatedUser,
default_tz: Tz,
saved: bool,
error: Option<&str>,
) -> PreferencesTemplate {
let current = user.user.timezone.as_deref();
let zones = TZ_VARIANTS
.iter()
.map(|tz| TzOption {
name: tz.name().to_string(),
selected: current == Some(tz.name()),
})
.collect();
PreferencesTemplate {
shell: shell.clone(),
zones,
effective_tz: shell.tz.name().to_string(),
default_tz: default_tz.name().to_string(),
inherits: current.is_none(),
saved,
error: error.map(|e| e.to_string()),
}
}
fn builtin_resources() -> Vec<Resource> {
vec![
Resource {
base_path: "/admin/users".to_string(),
nav_label: "Backend Users".to_string(),
list: backend_users_list_config(),
form: None,
permission: Some("backend.manage_users".to_string()),
},
Resource {
base_path: "/admin/roles".to_string(),
nav_label: "Roles".to_string(),
list: roles_list_config(),
form: None,
permission: Some("backend.manage_roles".to_string()),
},
]
}
fn builtin_settings() -> Vec<settings::SettingsItem> {
vec![
settings::SettingsItem {
code: "backend.administrators".to_string(),
label: "Administrators".to_string(),
description: "Manage backend administrator accounts.".to_string(),
category: "Users".to_string(),
order: 10,
icon: Some("users".to_string()),
permission: Some("backend.manage_users".to_string()),
link: Some("/admin/users".to_string()),
fields: Vec::new(),
},
settings::SettingsItem {
code: "backend.roles".to_string(),
label: "Roles".to_string(),
description: "Manage roles and their permissions.".to_string(),
category: "Users".to_string(),
order: 20,
icon: Some("shield".to_string()),
permission: Some("backend.manage_roles".to_string()),
link: Some("/admin/roles".to_string()),
fields: Vec::new(),
},
settings::brand::settings_item(),
]
}
fn backend_users_list_config() -> list::ListConfig {
list::ListConfig {
entity: "backend_users".to_string(),
title: "Backend Users".to_string(),
columns: vec![
list::ListColumn::new("username", "Username"),
list::ListColumn::new("email", "Email"),
list::ListColumn::new("first_name", "First name"),
list::ListColumn::new("last_name", "Last name"),
list::ListColumn::new("is_superuser", "Superuser").yes_no(),
list::ListColumn::new("is_active", "Active").yes_no(),
list::ListColumn::new("created_at", "Created").datetime(),
],
order_by: "created_at".to_string(),
order_dir: list::SortDir::Desc,
per_page: 25,
id_field: "id".to_string(),
edit_base: Some("/admin/users".to_string()),
creatable: false,
}
}
fn roles_list_config() -> list::ListConfig {
list::ListConfig {
entity: "backend_roles".to_string(),
title: "Roles".to_string(),
columns: vec![
list::ListColumn::new("code", "Code"),
list::ListColumn::new("name", "Name"),
list::ListColumn::new("created_at", "Created").datetime(),
],
order_by: "created_at".to_string(),
order_dir: list::SortDir::Desc,
per_page: 25,
id_field: "id".to_string(),
edit_base: Some("/admin/roles".to_string()),
creatable: true,
}
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
brand: String,
error: Option<String>,
}
#[derive(Template)]
#[template(path = "dashboard.html")]
struct DashboardTemplate {
shell: Shell,
username: String,
}
#[derive(Template)]
#[template(path = "setup.html")]
struct SetupTemplate {
brand: String,
zones: Vec<TzOption>,
error: Option<String>,
}
#[derive(Template)]
#[template(path = "preferences.html")]
struct PreferencesTemplate {
shell: Shell,
zones: Vec<TzOption>,
effective_tz: String,
default_tz: String,
inherits: bool,
saved: bool,
error: Option<String>,
}
struct TzOption {
name: String,
selected: bool,
}
#[derive(Clone)]
struct NavView {
label: String,
path: String,
active: bool,
icon: &'static str,
}
pub(crate) fn render<T: Template>(template: T) -> Response {
match template.render() {
Ok(html) => Html(html).into_response(),
Err(_) => render_error(),
}
}
pub(crate) fn render_error() -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
}
pub(crate) fn not_found() -> Response {
(StatusCode::NOT_FOUND, "Not found").into_response()
}
pub(crate) fn forbidden() -> Response {
(StatusCode::FORBIDDEN, "Forbidden").into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
}
async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
laterite_core::testing::connect_test(&[]).await
}
#[tokio::test]
async fn builtin_migrations_create_the_admin_tables() {
let (db, _guard) = empty_db().await;
laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
.await
.unwrap();
for table in ["backend_users", "settings"] {
let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
.fetch_optional(&db.pool)
.await;
assert!(
probe.is_ok(),
"{table} should exist after builtin_migrations"
);
}
}
#[tokio::test]
async fn brand_setting_overrides_config_and_blank_falls_back() {
let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
let state = AdminState {
auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
db: db.clone(),
nav: Arc::new(Vec::new()),
settings: Arc::new(Vec::new()),
permissions: Arc::new(builtin_permissions()),
secure_cookie: false,
timezone: Tz::UTC,
app_name: "Configured Name".to_string(),
brand_cache: Arc::new(RwLock::new(None)),
};
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.to_string(),
description: String::new(),
category: "General".to_string(),
order: 1,
icon: None,
permission: permission.map(str::to_string),
link: None,
fields: Vec::new(),
}
}
#[test]
fn settings_visibility_respects_permissions() {
let items = vec![
settings_item("public", None),
settings_item("gated", Some("backend.manage_users")),
];
let none = PermissionSet::new(false, Vec::<String>::new());
let codes: Vec<String> = visible_settings(&items, &none)
.into_iter()
.map(|i| i.code)
.collect();
assert_eq!(codes, ["public"]);
let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
assert_eq!(visible_settings(&items, &granted).len(), 2);
let superuser = PermissionSet::new(true, Vec::<String>::new());
assert_eq!(visible_settings(&items, &superuser).len(), 2);
}
#[test]
fn context_sidebar_follows_settings_links() {
let items = builtin_settings();
let superuser = PermissionSet::new(true, Vec::<String>::new());
let sidebar = |path: &str| resolve_nav_context(&[], &items, &superuser, path).0;
let active_path = |path: &str| -> Option<String> {
sidebar(path)
.into_iter()
.flat_map(|g| g.items)
.find(|i| i.active)
.map(|i| i.path)
};
assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
assert_eq!(
active_path("/admin/roles/42/edit").as_deref(),
Some("/admin/roles")
);
assert!(!sidebar("/admin/settings").is_empty());
assert_eq!(active_path("/admin/settings"), None);
assert_eq!(
active_path("/admin/settings/backend.roles").as_deref(),
Some("/admin/roles")
);
assert!(sidebar("/admin").is_empty());
}
#[test]
fn active_nav_lights_the_right_tab() {
let nav = vec![
NavLink {
label: "Dashboard".to_string(),
path: "/admin".to_string(),
icon: Some("layout-dashboard"),
},
NavLink {
label: "Pages".to_string(),
path: "/admin/pages".to_string(),
icon: None,
},
NavLink {
label: "Settings".to_string(),
path: "/admin/settings".to_string(),
icon: Some("settings"),
},
];
assert_eq!(
active_nav_path(&nav, false, "/admin").as_deref(),
Some("/admin")
);
assert_eq!(
active_nav_path(&nav, false, "/admin/pages/7/edit").as_deref(),
Some("/admin/pages")
);
assert_eq!(active_nav_path(&nav, false, "/admin/pages-archive"), None);
assert_eq!(active_nav_path(&nav, false, "/admin/preferences"), None);
assert_eq!(
active_nav_path(&nav, true, "/admin/users").as_deref(),
Some("/admin/settings")
);
assert_eq!(
active_nav_path(&nav, true, "/admin/settings").as_deref(),
Some("/admin/settings")
);
}
}