pub mod config;
pub mod models;
pub mod registry;
mod views;
pub mod widgets;
mod auth;
pub mod branding;
mod discovery;
mod engine;
mod error;
mod handlers;
mod inlines;
mod pagination;
mod permcheck;
mod rows;
mod static_assets;
mod util;
mod view;
pub mod files;
pub(crate) use auth::{login_get, login_post, logout_handler};
pub(crate) use error::AdminError;
pub use files::{file_descriptor, resolve_preview_kind};
pub(crate) use static_assets::admin_static_files;
pub(crate) use util::q;
pub use config::{
Action, ActionInvocation, ActionResult, ActionScope, ActionVariant, AdminConfig, AdminContext,
AdminModel, InlineKind, InlineModel, ToastLevel,
};
pub use registry::{AdminRegistration, AdminRegistry, App as AdminApp};
pub use handlers::dashboard::{builtin_recent_users_widget, builtin_total_models_widget};
pub use views::AdminView;
pub use widgets::{
BarPayload, CardPayload, CatalogEntry, ChartPoint, DonutPayload, DonutSlice, FeedItem,
FeedPayload, HeatmapCell, HeatmapPayload, HeatmapRow, KpiPayload, LinePayload, ProgressItem,
ProgressPayload, RadialPayload, RadialTrack, Series, Span, TableColumn, TablePayload, Widget,
WidgetDataFn, WidgetInstance, WidgetKind, WidgetParams, WidgetPayload, WidgetSection,
format_thousands, humanize_number,
};
use std::sync::Arc;
use umbral::prelude::*;
use umbral::web::post;
#[derive(Debug, Clone)]
pub enum DashboardModelsConfig {
All,
Hidden,
Only(Vec<String>),
}
impl Default for DashboardModelsConfig {
fn default() -> Self {
Self::All
}
}
#[derive(Debug, Clone)]
pub struct AdminPlugin {
registry: AdminRegistry,
widget_catalog: Vec<Widget>,
dashboard_sections: Vec<WidgetSection>,
branding: branding::AdminBranding,
base_path: String,
dashboard_models: DashboardModelsConfig,
dashboard_models_title: String,
dashboard_models_subtitle: Option<String>,
restore_last_path: bool,
custom_views: Vec<AdminView>,
}
impl Default for AdminPlugin {
fn default() -> Self {
Self {
registry: AdminRegistry::default(),
widget_catalog: Vec::new(),
dashboard_sections: Vec::new(),
branding: branding::AdminBranding::default(),
base_path: "/admin".to_string(),
dashboard_models: DashboardModelsConfig::default(),
dashboard_models_title: "Models".to_string(),
dashboard_models_subtitle: None,
restore_last_path: true,
custom_views: Vec::new(),
}
}
}
impl AdminPlugin {
pub fn register(mut self, model: AdminModel) -> Self {
self.registry.register("admin", model);
self
}
pub fn register_many(mut self, models: impl IntoIterator<Item = AdminModel>) -> Self {
for model in models {
self = self.register(model);
}
self
}
pub fn register_for(mut self, plugin_name: &str, model: AdminModel) -> Self {
self.registry.register(plugin_name, model);
self
}
pub fn register_for_many(
mut self,
plugin_name: &str,
models: impl IntoIterator<Item = AdminModel>,
) -> Self {
for model in models {
self = self.register_for(plugin_name, model);
}
self
}
pub fn register_widget(mut self, widget: Widget) -> Self {
self.widget_catalog.push(widget);
self
}
pub fn site_title(mut self, title: impl Into<String>) -> Self {
self.branding.site_title = title.into();
self
}
pub fn site_description(mut self, description: impl Into<String>) -> Self {
self.branding.site_description = description.into();
self
}
#[doc(hidden)]
pub fn branding_for_tests(&self) -> &branding::AdminBranding {
&self.branding
}
pub fn show_version(mut self, show: bool) -> Self {
self.branding.version_label = if show {
Some(
self.branding
.version_label
.unwrap_or_else(crate::branding::umbral_version_label),
)
} else {
None
};
self
}
pub fn version(mut self, label: impl Into<String>) -> Self {
self.branding.version_label = Some(label.into());
self
}
pub fn brand_color(mut self, color: impl Into<String>) -> Self {
self.branding.brand_color = color.into();
self
}
pub fn at(mut self, path: impl Into<String>) -> Self {
let raw = path.into();
let trimmed = raw.trim_matches('/');
self.base_path = if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}")
};
self
}
pub fn base_path(&self) -> &str {
&self.base_path
}
pub fn dashboard_models_hidden(mut self) -> Self {
self.dashboard_models = DashboardModelsConfig::Hidden;
self
}
pub fn dashboard_models_only<S: Into<String> + Clone>(mut self, tables: &[S]) -> Self {
self.dashboard_models =
DashboardModelsConfig::Only(tables.iter().cloned().map(Into::into).collect());
self
}
pub fn dashboard_models_all(mut self) -> Self {
self.dashboard_models = DashboardModelsConfig::All;
self
}
pub fn dashboard_section(mut self, section: WidgetSection) -> Self {
self.dashboard_sections.push(section);
self
}
pub fn dashboard_section_at(mut self, index: usize, section: WidgetSection) -> Self {
let i = index.min(self.dashboard_sections.len());
self.dashboard_sections.insert(i, section);
self
}
pub fn dashboard_models_title(mut self, title: impl Into<String>) -> Self {
self.dashboard_models_title = title.into();
self
}
pub fn dashboard_models_subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.dashboard_models_subtitle = Some(subtitle.into());
self
}
pub fn restore_last_path(mut self, enabled: bool) -> Self {
self.restore_last_path = enabled;
self
}
pub fn view(mut self, view: AdminView) -> Self {
self.custom_views.push(view);
self
}
pub fn views(mut self, views: impl IntoIterator<Item = AdminView>) -> Self {
self.custom_views.extend(views);
self
}
fn resolved_custom_views(&self) -> Vec<AdminView> {
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut out = Vec::with_capacity(self.custom_views.len());
for v in &self.custom_views {
let path = v.path();
if path.is_empty() {
tracing::error!(title = v.title(), "admin custom view rejected: empty path");
continue;
}
if !seen.insert(path) {
tracing::error!(path, "admin custom view rejected: duplicate path");
continue;
}
out.push(v.clone());
}
out
}
}
#[derive(Clone, Debug)]
struct AdminState {
registry: Arc<AdminRegistry>,
widget_catalog: Arc<Vec<Widget>>,
dashboard_sections: Arc<Vec<WidgetSection>>,
dashboard_models: DashboardModelsConfig,
dashboard_models_title: String,
dashboard_models_subtitle: Option<String>,
restore_last_path: bool,
custom_views: Arc<Vec<AdminView>>,
widget_gates: Arc<std::collections::HashMap<String, String>>,
}
impl AdminState {
fn config_for(&self, table: &str) -> Option<&AdminConfig> {
self.registry.get(table).map(|r| &r.model)
}
}
fn route(sub: &str, base: &str) -> String {
if sub.is_empty() {
return base.to_string();
}
format!("{base}{sub}")
}
impl Plugin for AdminPlugin {
fn name(&self) -> &'static str {
"admin"
}
fn dependencies(&self) -> &'static [&'static str] {
&["auth", "sessions"]
}
fn static_files(&self) -> Vec<umbral::plugin::StaticFile> {
admin_static_files()
}
fn static_dirs(&self) -> Vec<umbral::plugin::StaticDir> {
let source_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("assets");
vec![umbral::plugin::StaticDir::new("admin", source_dir)]
}
fn models(&self) -> Vec<umbral::migrate::ModelMeta> {
vec![
umbral::migrate::ModelMeta::for_::<crate::models::AdminUserPref>(),
umbral::migrate::ModelMeta::for_::<crate::models::AdminAuditLog>(),
]
}
fn routes(&self) -> Router {
let mut sealed_branding = self.branding.clone();
sealed_branding.base_path = self.base_path.clone();
sealed_branding.restore_last_path = self.restore_last_path;
let _ = branding::BRANDING.set(sealed_branding);
let mut sections: Vec<WidgetSection> = self.dashboard_sections.clone();
if !self.widget_catalog.is_empty() {
sections
.push(WidgetSection::new("Widgets").widgets(self.widget_catalog.iter().cloned()));
}
let mut catalog: Vec<Widget> = sections
.iter()
.flat_map(|s| s.widgets.iter().cloned())
.collect();
let resolved_views = self.resolved_custom_views();
let mut seen_keys: std::collections::HashSet<&str> =
catalog.iter().map(|w| w.key).collect();
let mut widget_gates: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for v in &resolved_views {
for w in v.sections().iter().flat_map(|s| s.widgets.iter()) {
if !seen_keys.insert(w.key) {
tracing::warn!(
widget_key = w.key,
view = v.path(),
"duplicate widget key across dashboard/custom views; \
the data endpoint resolves the first match"
);
}
catalog.push(w.clone());
if let Some(perm) = v.permission() {
widget_gates.insert(w.key.to_string(), perm.to_string());
}
}
}
let state = AdminState {
registry: Arc::new(self.registry.clone()),
widget_catalog: Arc::new(catalog),
dashboard_sections: Arc::new(sections),
dashboard_models: self.dashboard_models.clone(),
dashboard_models_title: self.dashboard_models_title.clone(),
dashboard_models_subtitle: self.dashboard_models_subtitle.clone(),
restore_last_path: self.restore_last_path,
custom_views: Arc::new(resolved_views.clone()),
widget_gates: Arc::new(widget_gates),
};
let mut router = Router::new()
.route(
&route("/login", &self.base_path),
axum::routing::get(login_get).post(login_post),
)
.route(
&route("/logout", &self.base_path),
axum::routing::get(logout_handler),
)
.route(
&route("", &self.base_path),
axum::routing::get(handlers::list::index),
)
.route(
&route("/", &self.base_path),
axum::routing::get(handlers::list::index),
)
.route(
&route("/{table}/", &self.base_path),
axum::routing::get(handlers::list::list),
)
.route(
&route("/{table}/new", &self.base_path),
axum::routing::get(handlers::crud::new_form).post(handlers::crud::create),
)
.route(
&route("/{table}/action", &self.base_path),
post(handlers::actions::run_action),
)
.route(
&route("/{table}/rows", &self.base_path),
axum::routing::get(handlers::list::rows_fragment),
)
.route(
&route("/{table}/columns/{column}/toggle", &self.base_path),
post(handlers::list::toggle_column_visibility),
)
.route(
&route("/{table}/filter-dialog", &self.base_path),
axum::routing::get(handlers::list::filter_dialog_handler),
)
.route(
&route("/{table}/new-sheet", &self.base_path),
axum::routing::get(handlers::sheet::new_sheet),
)
.route(
&route("/{table}/{id}/_confirm-delete", &self.base_path),
axum::routing::get(handlers::sheet::confirm_delete_dialog),
)
.route(
&route("/{table}/{id}/sheet", &self.base_path),
axum::routing::get(handlers::sheet::preview_sheet),
)
.route(
&route("/{table}/{id}/edit-sheet", &self.base_path),
axum::routing::get(handlers::sheet::edit_sheet_handler),
)
.route(
&route("/{table}/{id}", &self.base_path),
axum::routing::get(handlers::crud::detail),
)
.route(
&route("/{table}/{id}/edit", &self.base_path),
axum::routing::get(handlers::crud::edit_form).post(handlers::crud::update),
)
.route(
&route("/{table}/create", &self.base_path),
axum::routing::post(handlers::sheet::sheet_create),
)
.route(
&route("/{table}/{id}", &self.base_path),
axum::routing::delete(handlers::crud::htmx_delete),
)
.route(
&route("/{table}/{id}/delete", &self.base_path),
post(handlers::crud::delete),
)
.route(
&route("/{table}/actions/{key}", &self.base_path),
axum::routing::post(handlers::actions::dispatch_action),
)
.route(
&route("/api/{table}/{field}/options/resolve", &self.base_path),
axum::routing::get(handlers::fk_picker::fk_options_resolve),
)
.route(
&route("/api/{table}/{field}/options", &self.base_path),
axum::routing::get(handlers::fk_picker::fk_options),
)
.route(
&route("/{table}/{id}/cell/{field}/edit", &self.base_path),
axum::routing::get(handlers::inline_edit::cell_edit_get),
)
.route(
&route("/{table}/{id}/cell/{field}", &self.base_path),
axum::routing::post(handlers::inline_edit::cell_edit_post),
)
.route(
&route("/{table}/{id}/change-password", &self.base_path),
axum::routing::post(handlers::sheet::change_password_handler),
)
.route(
&route("/api/prefs", &self.base_path),
axum::routing::get(handlers::prefs::get_prefs_handler)
.put(handlers::prefs::put_prefs_handler),
)
.route(
&route("/{table}/{id}/history", &self.base_path),
axum::routing::get(handlers::history::history_handler),
)
.route(
&route("/api/dashboard/catalog", &self.base_path),
axum::routing::get(handlers::dashboard::dashboard_catalog),
)
.route(
&route("/api/dashboard/layout", &self.base_path),
axum::routing::get(handlers::dashboard::dashboard_layout_get)
.put(handlers::dashboard::dashboard_layout_put),
)
.route(
&route("/api/dashboard/widgets/{key}/data", &self.base_path),
axum::routing::get(handlers::dashboard::dashboard_widget_data),
)
.route(
&route("/upload-image", &self.base_path),
post(handlers::upload::upload_image),
)
.route(
&route("/api/palette", &self.base_path),
axum::routing::get(handlers::palette::palette_fragment),
)
.route(
&route("/api/palette/search", &self.base_path),
axum::routing::get(handlers::palette::palette_search),
)
;
for v in &resolved_views {
let slug = v.path().to_string();
let full = route(&format!("/custom-views/{}/", v.path()), &self.base_path);
router = router.route(
&full,
axum::routing::get({
let slug = slug.clone();
move |state: axum::extract::State<AdminState>,
headers: axum::http::HeaderMap| {
let slug = slug.clone();
async move {
crate::handlers::custom_view::custom_view(state, headers, slug).await
}
}
}),
);
}
router.with_state(state)
}
fn route_paths(&self) -> Vec<umbral::routes::RouteSpec> {
use umbral::routes::RouteSpec;
let g = || vec!["GET"];
let p = || vec!["POST"];
let gp = || vec!["GET", "POST"];
let gpd = || vec!["GET", "POST", "DELETE"];
let gput = || vec!["GET", "PUT"];
let mut specs = vec![
RouteSpec::new(&route("", &self.base_path), g()),
RouteSpec::new(&route("/", &self.base_path), g()),
RouteSpec::new(&route("/login", &self.base_path), gp()),
RouteSpec::new(&route("/logout", &self.base_path), g()),
RouteSpec::new(&route("/{table}/", &self.base_path), g()),
RouteSpec::new(&route("/{table}/new", &self.base_path), gp()),
RouteSpec::new(&route("/{table}/action", &self.base_path), p()),
RouteSpec::new(&route("/{table}/rows", &self.base_path), g()),
RouteSpec::new(&route("/{table}/filter-dialog", &self.base_path), g()),
RouteSpec::new(&route("/{table}/new-sheet", &self.base_path), g()),
RouteSpec::new(&route("/{table}/create", &self.base_path), p()),
RouteSpec::new(&route("/{table}/{id}", &self.base_path), gpd()),
RouteSpec::new(&route("/{table}/{id}/edit", &self.base_path), gp()),
RouteSpec::new(&route("/{table}/{id}/edit-sheet", &self.base_path), g()),
RouteSpec::new(&route("/{table}/{id}/sheet", &self.base_path), g()),
RouteSpec::new(&route("/{table}/{id}/delete", &self.base_path), p()),
RouteSpec::new(
&route("/{table}/{id}/_confirm-delete", &self.base_path),
g(),
),
RouteSpec::new(&route("/{table}/{id}/history", &self.base_path), g()),
RouteSpec::new(
&route("/{table}/{id}/change-password", &self.base_path),
p(),
),
RouteSpec::new(&route("/{table}/{id}/cell/{field}", &self.base_path), p()),
RouteSpec::new(
&route("/{table}/{id}/cell/{field}/edit", &self.base_path),
g(),
),
RouteSpec::new(&route("/{table}/actions/{key}", &self.base_path), p()),
RouteSpec::new(&route("/api/{table}/{field}/options", &self.base_path), g()),
RouteSpec::new(
&route("/api/{table}/{field}/options/resolve", &self.base_path),
g(),
),
RouteSpec::new(&route("/api/prefs", &self.base_path), gput()),
RouteSpec::new(&route("/upload-image", &self.base_path), p()),
RouteSpec::new(&route("/api/palette", &self.base_path), g()),
RouteSpec::new(&route("/api/palette/search", &self.base_path), g()),
RouteSpec::new(&route("/api/dashboard/catalog", &self.base_path), g()),
RouteSpec::new(&route("/api/dashboard/layout", &self.base_path), gput()),
RouteSpec::new(
&route("/api/dashboard/widgets/{key}/data", &self.base_path),
g(),
),
];
for v in &self.resolved_custom_views() {
specs.push(RouteSpec::new(
&format!("{}/custom-views/{}/", self.base_path, v.path()),
g(),
));
}
specs
}
fn on_ready(
&self,
_ctx: &umbral::plugin::AppContext,
) -> Result<(), umbral::plugin::PluginError> {
if umbral_sessions::configured_same_site() == umbral_sessions::SameSite::None {
tracing::warn!(
"umbral-admin: the session cookie is SameSite=None, which removes the \
cross-site-request CSRF defense the admin's mutating handlers rely on. \
Mount a CSRF middleware (umbral-security's SecurityPlugin) so admin \
create/update/delete/upload/prefs actions can't be forged cross-site, \
or keep the session cookie at SameSite=Lax/Strict for same-origin admin use."
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn admin_model_defaults() {
let m = AdminModel::new("post");
assert_eq!(m.get_list_per_page(), 25);
assert!(m.inlines.is_empty());
assert!(m.label.is_none());
assert!(m.icon.is_none());
}
#[test]
fn admin_config_alias_compiles() {
let _: AdminConfig = AdminModel::new("test");
}
#[test]
fn static_files_use_unified_static_url() {
let files = AdminPlugin::default().static_files();
let paths: Vec<&str> = files.iter().map(|f| f.url_path).collect();
assert!(
paths.contains(&"/static/admin/admin.css"),
"admin.css should mount at /static/admin/admin.css, got {paths:?}"
);
assert!(
paths.contains(&"/static/admin/admin.js"),
"admin.js should mount at /static/admin/admin.js, got {paths:?}"
);
for f in &files {
assert!(
f.body.len() > 100,
"{} should ship embedded bytes, got {} bytes",
f.url_path,
f.body.len()
);
}
}
#[test]
fn static_dirs_maps_admin_namespace_to_existing_assets_dir() {
let dirs = AdminPlugin::default().static_dirs();
assert_eq!(dirs.len(), 1, "admin contributes exactly one static dir");
let dir = &dirs[0];
assert_eq!(dir.namespace, "admin");
assert!(
dir.source_dir.join("admin.css").is_file(),
"{} should contain admin.css",
dir.source_dir.display()
);
assert!(
dir.source_dir.join("admin.js").is_file(),
"{} should contain admin.js",
dir.source_dir.display()
);
}
}
#[cfg(test)]
mod custom_view_wiring_tests {
use super::*;
use crate::views::AdminView;
use crate::widgets::{
KpiPayload, Widget, WidgetDataFn, WidgetKind, WidgetPayload, WidgetSection,
};
fn tiny_kpi(key: &'static str) -> Widget {
Widget {
key,
title: "T".into(),
kind: WidgetKind::Kpi,
default_span: Default::default(),
permission: None,
data: WidgetDataFn::new(|_user| async {
WidgetPayload::Kpi(KpiPayload {
value: "0".into(),
unit: None,
delta: None,
sparkline: None,
})
}),
default_period: None,
}
}
#[test]
fn resolved_custom_views_drops_duplicate_paths() {
let plugin = AdminPlugin::default()
.view(AdminView::new("reports/sales", "A"))
.view(AdminView::new("reports/sales", "dup B")) .view(AdminView::new("login", "safe under the namespace")) .view(AdminView::new("reports/ok", "C"));
let resolved = plugin.resolved_custom_views();
let paths: Vec<&str> = resolved.iter().map(|v| v.path()).collect();
assert_eq!(
paths,
vec!["reports/sales", "login", "reports/ok"],
"only the exact duplicate is dropped (first wins); a 'login' path is fine under /custom-views/"
);
let _router = plugin.routes();
}
#[test]
fn view_registers_and_flattens_widgets_into_catalog() {
let plugin = AdminPlugin::default().view(
AdminView::new("reports/sales", "Sales")
.section(WidgetSection::new("S").widget(tiny_kpi("rpt_sales_total"))),
);
assert_eq!(plugin.custom_views.len(), 1);
assert_eq!(plugin.custom_views[0].path(), "reports/sales");
let catalog_keys: Vec<&str> = plugin
.custom_views
.iter()
.flat_map(|v| v.sections().iter())
.flat_map(|s| s.widgets.iter())
.map(|w| w.key)
.collect();
assert!(
catalog_keys.contains(&"rpt_sales_total"),
"the view's widget key should be flattenable into the catalog, got {catalog_keys:?}"
);
}
}