pub mod config;
pub mod models;
pub mod registry;
pub mod widgets;
mod auth;
mod branding;
mod discovery;
mod engine;
mod inlines;
mod error;
mod handlers;
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 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,
}
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,
}
}
}
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
}
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
}
}
#[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,
}
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 catalog: Vec<Widget> = sections
.iter()
.flat_map(|s| s.widgets.iter().cloned())
.collect();
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,
};
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),
)
.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"];
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(),
),
]
}
fn on_ready(&self, _ctx: &umbral::plugin::AppContext) -> Result<(), umbral::plugin::PluginError> {
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()
);
}
}