use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::core::SqlValue;
use crate::sql::Pool;
use axum::routing::{get, post};
use axum::Router;
use super::errors::AdminError;
use super::views;
pub type AdminActionFuture<'a> = Pin<Box<dyn Future<Output = Result<(), AdminError>> + Send + 'a>>;
pub type AdminActionFn = Arc<
dyn for<'a> Fn(&'a crate::sql::Pool, &'a [SqlValue]) -> AdminActionFuture<'a> + Send + Sync,
>;
pub(crate) type AdminActionRegistry = HashMap<&'static str, HashMap<&'static str, AdminActionFn>>;
pub fn router(pool: impl Into<Pool>) -> Router {
Builder::new(pool).build()
}
#[must_use]
pub struct Builder {
pool: Pool,
config: Config,
}
#[derive(Clone, Default)]
pub(crate) struct Config {
pub(crate) title: Option<String>,
pub(crate) subtitle: Option<String>,
pub(crate) brand_name: Option<String>,
pub(crate) brand_tagline: Option<String>,
pub(crate) brand_logo_url: Option<String>,
pub(crate) theme_mode: Option<String>,
pub(crate) tenant_brand_css: Option<String>,
pub(crate) allowed_tables: Option<HashSet<String>>,
pub(crate) read_only_tables: HashSet<String>,
pub(crate) read_only_all: bool,
pub(crate) actions: AdminActionRegistry,
pub(crate) user_perms: Option<HashSet<String>>,
pub(crate) tenant_mode: bool,
pub(crate) impersonated_by: Option<i64>,
pub(crate) admin_prefix: String,
pub(crate) change_password_url: Option<String>,
pub(crate) audit_url: String,
pub(crate) static_url: String,
pub(crate) skip_count_tables: HashSet<String>,
}
impl Builder {
pub fn new(pool: impl Into<Pool>) -> Self {
let pool = pool.into();
let mut config = Config::default();
config.admin_prefix = "/__admin".to_owned();
config.audit_url = "/__audit".to_owned();
config.static_url = "/__static__".to_owned();
Self { pool, config }
}
#[cfg(feature = "config")]
pub fn from_settings(pool: impl Into<Pool>, settings: &crate::config::Settings) -> Self {
let mut builder = Self::new(pool);
let admin = &settings.admin;
if let Some(t) = admin.title.as_deref() {
builder = builder.title(t);
} else if let Some(brand_name) = settings.brand.name.as_deref() {
builder = builder.title(brand_name);
}
if let Some(s) = admin.subtitle.as_deref() {
builder = builder.subtitle(s);
} else if let Some(t) = settings.brand.tagline.as_deref() {
builder = builder.subtitle(t);
}
if let Some(url) = admin.logo_url.as_deref() {
builder = builder.brand_logo_url(url);
} else if let Some(url) = settings.brand.logo_url.as_deref() {
builder = builder.brand_logo_url(url);
}
if let Some(mode) = admin
.theme_mode
.as_deref()
.or(settings.brand.theme_mode.as_deref())
{
builder = builder.theme_mode(mode);
}
let url_prefix = admin
.url_prefix
.as_deref()
.or(settings.routes.admin_url.as_deref());
if let Some(prefix) = url_prefix {
builder = builder.admin_prefix(prefix);
}
if let Some(audit_url) = settings.routes.audit_url.as_deref() {
builder = builder.audit_url(audit_url);
}
if let Some(static_url) = settings.routes.static_url.as_deref() {
builder = builder.static_url(static_url);
}
if let Some(change_password_url) = settings.routes.change_password_url.as_deref() {
builder = builder.change_password_url(change_password_url);
}
if !admin.allowed_tables.is_empty() {
builder = builder.show_only(admin.allowed_tables.iter().cloned());
}
if !admin.read_only_tables.is_empty() {
builder = builder.read_only(admin.read_only_tables.iter().cloned());
}
builder
}
#[must_use]
pub fn admin_prefix(mut self, prefix: impl Into<String>) -> Self {
let s: String = prefix.into();
let trimmed = s.trim_end_matches('/').to_owned();
self.config.admin_prefix = trimmed;
self
}
#[must_use]
pub fn audit_url(mut self, url: impl Into<String>) -> Self {
let s: String = url.into();
let trimmed = s.trim_end_matches('/').to_owned();
self.config.audit_url = trimmed;
self
}
#[must_use]
pub fn static_url(mut self, url: impl Into<String>) -> Self {
let s: String = url.into();
let trimmed = s.trim_end_matches('/').to_owned();
self.config.static_url = trimmed;
self
}
#[must_use]
pub fn change_password_url(mut self, url: impl Into<String>) -> Self {
self.config.change_password_url = Some(url.into());
self
}
pub fn show_only<I, S>(mut self, tables: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.config.allowed_tables = Some(tables.into_iter().map(Into::into).collect());
self
}
pub fn read_only<I, S>(mut self, tables: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.config
.read_only_tables
.extend(tables.into_iter().map(Into::into));
self
}
pub fn read_only_all(mut self) -> Self {
self.config.read_only_all = true;
self
}
pub fn skip_count_for<I, S>(mut self, tables: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.config
.skip_count_tables
.extend(tables.into_iter().map(Into::into));
self
}
#[must_use]
pub fn impersonated_by(mut self, operator_id: i64) -> Self {
self.config.impersonated_by = Some(operator_id);
self
}
#[must_use]
pub fn tenant_mode(mut self) -> Self {
self.config.tenant_mode = true;
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.config.title = Some(title.into());
self
}
pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.config.subtitle = Some(subtitle.into());
self
}
#[must_use]
pub fn brand_name(mut self, name: impl Into<String>) -> Self {
self.config.brand_name = Some(name.into());
self
}
#[must_use]
pub fn brand_tagline(mut self, tagline: impl Into<String>) -> Self {
self.config.brand_tagline = Some(tagline.into());
self
}
#[must_use]
pub fn brand_logo_url(mut self, url: impl Into<String>) -> Self {
self.config.brand_logo_url = Some(url.into());
self
}
#[must_use]
pub fn theme_mode(mut self, mode: impl Into<String>) -> Self {
self.config.theme_mode = Some(mode.into());
self
}
#[must_use]
pub fn tenant_brand_css(mut self, css: impl Into<String>) -> Self {
self.config.tenant_brand_css = Some(css.into());
self
}
pub fn with_user_perms<I: IntoIterator<Item = String>>(mut self, perms: I) -> Self {
self.config.user_perms = Some(perms.into_iter().collect());
self
}
pub fn register_action<F>(
mut self,
model_table: &'static str,
action_name: &'static str,
handler: F,
) -> Self
where
F: for<'a> Fn(&'a crate::sql::Pool, &'a [SqlValue]) -> AdminActionFuture<'a>
+ Send
+ Sync
+ 'static,
{
self.config
.actions
.entry(model_table)
.or_default()
.insert(action_name, Arc::new(handler));
self
}
pub fn build(self) -> Router {
let audit_path = self.config.audit_url.clone();
let audit_cleanup_path = format!("{audit_path}/cleanup");
Router::new()
.route("/", get(views::index))
.route(&audit_path, get(super::audit::audit_log_view))
.route(
&audit_cleanup_path,
post(super::audit::audit_cleanup_submit),
)
.route(
"/{table}",
get(views::table_view).post(views::create_submit),
)
.route("/{table}/new", get(views::create_form))
.route("/{table}/__action", post(views::action_submit))
.route(
"/{table}/{pk}",
get(views::detail_view).post(views::update_submit),
)
.route("/{table}/{pk}/edit", get(views::edit_form))
.route("/{table}/{pk}/delete", post(views::delete_submit))
.with_state(AppState {
pool: self.pool,
config: Arc::new(self.config),
})
}
}
#[derive(Clone)]
pub(crate) struct AppState {
pub(crate) pool: Pool,
pub(crate) config: Arc<Config>,
}
impl AppState {
pub(crate) fn is_visible(&self, table: &str) -> bool {
let allowlist_ok = self
.config
.allowed_tables
.as_ref()
.is_none_or(|allowed| allowed.contains(table));
if !allowlist_ok {
return false;
}
if let Some(perms) = &self.config.user_perms {
return perms.contains(&format!("{table}.view"));
}
true
}
pub(crate) fn scope_visible(&self, scope: crate::core::ModelScope) -> bool {
if !self.config.tenant_mode {
return true;
}
scope == crate::core::ModelScope::Tenant
}
pub(crate) fn is_read_only(&self, table: &str) -> bool {
if self.config.read_only_all || self.config.read_only_tables.contains(table) {
return true;
}
if let Some(perms) = &self.config.user_perms {
return !perms.contains(&format!("{table}.change"));
}
false
}
pub(crate) fn count_skipped_for_table(&self, table: &str) -> bool {
self.config.skip_count_tables.contains(table)
}
pub(crate) fn can_add(&self, table: &str) -> bool {
if self.config.read_only_all || self.config.read_only_tables.contains(table) {
return false;
}
if let Some(perms) = &self.config.user_perms {
return perms.contains(&format!("{table}.add"));
}
true
}
pub(crate) fn can_delete(&self, table: &str) -> bool {
if self.config.read_only_all || self.config.read_only_tables.contains(table) {
return false;
}
if let Some(perms) = &self.config.user_perms {
return perms.contains(&format!("{table}.delete"));
}
true
}
pub(crate) fn action_handler(&self, table: &str, action: &str) -> Option<AdminActionFn> {
self.config
.actions
.get(table)
.and_then(|m| m.get(action))
.cloned()
}
}
#[cfg(all(test, feature = "postgres"))]
mod scope_filter_tests {
use super::*;
use crate::core::ModelScope;
use sqlx::PgPool;
use std::sync::Arc;
fn lazy_pg_pool() -> sqlx::PgPool {
PgPool::connect_lazy("postgres://_:_@127.0.0.1:1/_unused")
.expect("connect_lazy never fails")
}
fn state_with(tenant_mode: bool) -> AppState {
let mut cfg = Config::default();
cfg.tenant_mode = tenant_mode;
AppState {
pool: Pool::Postgres(lazy_pg_pool()),
config: Arc::new(cfg),
}
}
#[tokio::test]
async fn standalone_admin_sees_every_scope() {
let state = state_with(false);
assert!(state.scope_visible(ModelScope::Tenant));
assert!(state.scope_visible(ModelScope::Registry));
}
#[tokio::test]
async fn tenant_admin_hides_registry_scoped_models() {
let state = state_with(true);
assert!(state.scope_visible(ModelScope::Tenant));
assert!(!state.scope_visible(ModelScope::Registry));
}
#[tokio::test]
async fn tenant_mode_setter_flips_flag() {
let pool = PgPool::connect_lazy("postgres://_:_@127.0.0.1:1/_unused")
.expect("connect_lazy never fails");
let builder = Builder::new(pool).tenant_mode();
assert!(builder.config.tenant_mode);
}
#[tokio::test]
async fn admin_prefix_defaults_to_admin_underscore() {
let pool = lazy_pg_pool();
let builder = Builder::new(pool);
assert_eq!(builder.config.admin_prefix, "/__admin");
}
#[tokio::test]
async fn skip_count_for_marks_tables_and_checker_reads_them() {
let pool = lazy_pg_pool();
let b = Builder::new(pool).skip_count_for(["audit_log", "events"]);
let state = AppState {
pool: Pool::Postgres(lazy_pg_pool()),
config: Arc::new(b.config),
};
assert!(state.count_skipped_for_table("audit_log"));
assert!(state.count_skipped_for_table("events"));
assert!(!state.count_skipped_for_table("post"));
assert!(!state.count_skipped_for_table(""));
}
#[tokio::test]
async fn skip_count_for_unions_across_calls() {
let pool = lazy_pg_pool();
let b = Builder::new(pool)
.skip_count_for(["audit_log"])
.skip_count_for(["events"]);
let state = AppState {
pool: Pool::Postgres(lazy_pg_pool()),
config: Arc::new(b.config),
};
assert!(state.count_skipped_for_table("audit_log"));
assert!(state.count_skipped_for_table("events"));
}
#[tokio::test]
async fn admin_prefix_setter_strips_trailing_slash() {
let pool = lazy_pg_pool();
let b = Builder::new(pool).admin_prefix("/admin/");
assert_eq!(b.config.admin_prefix, "/admin");
}
#[tokio::test]
async fn admin_prefix_supports_empty_for_root_mount() {
let pool = lazy_pg_pool();
let b = Builder::new(pool).admin_prefix("");
assert_eq!(b.config.admin_prefix, "");
}
#[cfg(feature = "config")]
#[tokio::test]
async fn from_settings_applies_admin_section_overrides() {
use crate::config::{AdminSettings, Settings};
let mut settings = Settings::default();
settings.admin = AdminSettings {
title: Some("Acme Admin".into()),
subtitle: Some("Tenants".into()),
logo_url: Some("/assets/acme.png".into()),
theme_mode: Some("dark".into()),
url_prefix: Some("/admin".into()),
allowed_tables: vec!["post".into(), "author".into()],
read_only_tables: vec!["audit_log".into()],
..Default::default()
};
let b = Builder::from_settings(lazy_pg_pool(), &settings);
assert_eq!(b.config.title.as_deref(), Some("Acme Admin"));
assert_eq!(b.config.subtitle.as_deref(), Some("Tenants"));
assert_eq!(b.config.brand_logo_url.as_deref(), Some("/assets/acme.png"));
assert_eq!(b.config.theme_mode.as_deref(), Some("dark"));
assert_eq!(b.config.admin_prefix, "/admin");
let allowed: Vec<String> = b
.config
.allowed_tables
.as_ref()
.map(|s| s.iter().cloned().collect())
.unwrap_or_default();
assert!(allowed.contains(&"post".to_string()));
assert!(allowed.contains(&"author".to_string()));
assert!(b.config.read_only_tables.contains("audit_log"));
}
#[cfg(feature = "config")]
#[tokio::test]
async fn from_settings_falls_back_to_brand_section() {
use crate::config::{BrandSettings, Settings};
let mut settings = Settings::default();
settings.brand = BrandSettings {
name: Some("Acme".into()),
tagline: Some("Things".into()),
logo_url: Some("/brand/logo.png".into()),
theme_mode: Some("light".into()),
..Default::default()
};
let b = Builder::from_settings(lazy_pg_pool(), &settings);
assert_eq!(b.config.title.as_deref(), Some("Acme"));
assert_eq!(b.config.subtitle.as_deref(), Some("Things"));
assert_eq!(b.config.brand_logo_url.as_deref(), Some("/brand/logo.png"));
assert_eq!(b.config.theme_mode.as_deref(), Some("light"));
}
#[cfg(feature = "config")]
#[tokio::test]
async fn from_settings_admin_url_prefix_wins_over_routes_section() {
use crate::config::{AdminSettings, RoutesSettings, Settings};
let mut settings = Settings::default();
settings.admin = AdminSettings {
url_prefix: Some("/custom-admin".into()),
..Default::default()
};
settings.routes = RoutesSettings {
admin_url: Some("/admin".into()),
..Default::default()
};
let b = Builder::from_settings(lazy_pg_pool(), &settings);
assert_eq!(b.config.admin_prefix, "/custom-admin");
}
}