umbral_admin/lib.rs
1//! umbral-admin — auto-generated CRUD admin for umbral models.
2//!
3//! Drop-in admin interface for any umbral project. Register the
4//! [`AdminPlugin`] on `App::builder()` and every model the
5//! migration registry knows about gets:
6//!
7//! - A list view at `/admin/<table>/` with all rows in a table
8//! - A detail view at `/admin/<table>/<id>` with every field
9//! - A create form at `/admin/<table>/new`
10//! - An edit form at `/admin/<table>/<id>/edit`
11//! - A delete action at `POST /admin/<table>/<id>/delete`
12//!
13//! Plus a registered-models index at `/admin/`.
14//!
15//! ## Customizing per-model display
16//!
17//! Register an [`AdminModel`] for a model to control list columns, filter
18//! facets, search, ordering, bulk actions, and readonly fields. See
19//! [`AdminPlugin::register`] and the [`config`] module.
20//!
21//! ## Auth
22//!
23//! Every admin route requires a session-backed staff user. If the
24//! session is missing or the user is not staff, the handler redirects
25//! to `GET /admin/login?next=<current-url>`. `POST /admin/login` verifies
26//! credentials via [`umbral_auth::authenticate`], creates a session via
27//! [`umbral_sessions::login`], then redirects to `next`.
28//!
29//! ## Templates
30//!
31//! Six `include_str!`-embedded Jinja templates live in `templates/`.
32//! The admin owns its own minijinja `Environment`. `admin/base.html`
33//! is the shell (sidebar + topbar + content slot); the other five
34//! extend it.
35//!
36//! ## Form widgets
37//!
38//! Inputs dispatch per [`SqlType`]:
39//!
40//! | SqlType | Input |
41//! |---|---|
42//! | `SmallInt`, `Integer`, `BigInt` | `<input type="number">` |
43//! | `Real`, `Double` | `<input type="number" step="any">` |
44//! | `Boolean` | `<input type="checkbox">` |
45//! | `Text`, `Uuid` | `<input type="text">` |
46//! | `Date` | `<input type="date">` |
47//! | `Time` | `<input type="time">` |
48//! | `Timestamptz` | `<input type="datetime-local">` |
49//!
50//! Nullable fields skip the `required` attribute.
51
52pub mod config;
53pub mod models;
54pub mod registry;
55mod views;
56pub mod widgets;
57
58mod auth;
59mod branding;
60mod discovery;
61mod engine;
62mod error;
63mod handlers;
64mod inlines;
65mod pagination;
66mod permcheck;
67mod rows;
68mod static_assets;
69mod util;
70mod view;
71
72pub mod files;
73
74pub(crate) use auth::{login_get, login_post, logout_handler};
75pub(crate) use error::AdminError;
76pub use files::{file_descriptor, resolve_preview_kind};
77pub(crate) use static_assets::admin_static_files;
78pub(crate) use util::q;
79
80pub use config::{
81 Action, ActionInvocation, ActionResult, ActionScope, ActionVariant, AdminConfig, AdminContext,
82 AdminModel, InlineKind, InlineModel, ToastLevel,
83};
84pub use registry::{AdminRegistration, AdminRegistry, App as AdminApp};
85// The two builtin dashboard widgets — `Models by Plugin` (bar)
86// and `Recent Signups` (feed). Used to be auto-prepended to the
87// catalog; now exposed as public functions so the caller can
88// register them at the position they want and resize via
89// `.with_span(cols, rows)`. See `AdminPlugin::register_widget`
90// for the wiring shape.
91pub use handlers::dashboard::{builtin_recent_users_widget, builtin_total_models_widget};
92pub use views::AdminView;
93pub use widgets::{
94 BarPayload, CardPayload, CatalogEntry, ChartPoint, DonutPayload, DonutSlice, FeedItem,
95 FeedPayload, HeatmapCell, HeatmapPayload, HeatmapRow, KpiPayload, LinePayload, ProgressItem,
96 ProgressPayload, RadialPayload, RadialTrack, Series, Span, TableColumn, TablePayload, Widget,
97 WidgetDataFn, WidgetInstance, WidgetKind, WidgetParams, WidgetPayload, WidgetSection,
98 format_thousands, humanize_number,
99};
100
101use std::sync::Arc;
102
103use umbral::prelude::*;
104use umbral::web::post;
105
106// =========================================================================
107// Plugin struct
108// =========================================================================
109
110/// The plugin. Mounts every admin route under `/admin`.
111///
112/// Use [`AdminPlugin::register`] to attach an [`AdminModel`] before
113/// passing the plugin to `App::builder().plugin(...)`.
114///
115/// ```ignore
116/// use umbral_admin::{AdminPlugin, AdminModel, Action};
117///
118/// let admin = AdminPlugin::default()
119/// .register(
120/// AdminModel::new("post")
121/// .list_display(&["title", "author", "published_at"])
122/// .list_filter(&["published"])
123/// .search_fields(&["title", "body"])
124/// .ordering(&["-published_at"])
125/// .readonly_fields(&["created_at"])
126/// .actions(vec![Action::delete_selected()]),
127/// );
128///
129/// App::builder()
130/// .plugin(AuthPlugin::default())
131/// .plugin(admin)
132/// .build()?;
133/// ```
134/// How the dashboard renders its "Models" cards section.
135///
136/// Default: [`Self::All`] — every registered model gets a card.
137/// This works for a 5-20 model app but turns into a wall of 200
138/// cards on a real-world enterprise install. Use [`Self::Only`]
139/// to pick a curated subset, or [`Self::Hidden`] to drop the
140/// section entirely (e.g. when the operator's primary view is
141/// purely widget-driven).
142#[derive(Debug, Clone)]
143pub enum DashboardModelsConfig {
144 /// Default — show a card for every registered model.
145 All,
146 /// Hide the section entirely. The dashboard becomes:
147 /// greeting → quick stats → widgets, no model grid.
148 Hidden,
149 /// Show only these tables, in the given order. Unknown
150 /// table names are dropped silently (typo-safe; if a
151 /// plugin you reference is unregistered the rest still
152 /// render).
153 Only(Vec<String>),
154}
155
156impl Default for DashboardModelsConfig {
157 fn default() -> Self {
158 Self::All
159 }
160}
161
162#[derive(Debug, Clone)]
163pub struct AdminPlugin {
164 registry: AdminRegistry,
165 widget_catalog: Vec<Widget>,
166 /// Explicit named sections (each with title + subtitle + widget
167 /// list). Empty by default — back-compat for apps that only use
168 /// the legacy `register_widget` call. When non-empty, the
169 /// dashboard renders these sections first; any widgets in
170 /// `widget_catalog` get an implicit final "Widgets" section.
171 dashboard_sections: Vec<WidgetSection>,
172 branding: branding::AdminBranding,
173 /// Gap 107: base URL prefix for every admin route. Default
174 /// `/admin`. Override with `AdminPlugin::default().at("/myadmin")`.
175 /// Always normalised to one leading slash, no trailing slash.
176 base_path: String,
177 /// Dashboard model-cards config. Defaults to `All` so the
178 /// dashboard does something sensible on a fresh install.
179 dashboard_models: DashboardModelsConfig,
180 /// Heading shown above the model-cards section. Default
181 /// "Models" — override with `.dashboard_models_title(...)`.
182 dashboard_models_title: String,
183 /// Optional one-line subtitle under the heading.
184 dashboard_models_subtitle: Option<String>,
185 /// gaps2 #33 — "restore where I left off" feature flag. Default
186 /// `true` (on by default; opt out to disable).
187 /// When `true`: `/admin/` 302-redirects to `last_path` if one is
188 /// stored; the changelist handler writes `last_path` on every visit;
189 /// the "Home" breadcrumb carries `?dashboard=1` as an escape hatch.
190 /// When `false`: `/admin/` always renders the dashboard; the
191 /// changelist handler skips the `last_path` write (no dead data).
192 restore_last_path: bool,
193 /// Developer-registered custom views (widget pages at arbitrary paths).
194 custom_views: Vec<AdminView>,
195}
196
197impl Default for AdminPlugin {
198 fn default() -> Self {
199 Self {
200 registry: AdminRegistry::default(),
201 widget_catalog: Vec::new(),
202 dashboard_sections: Vec::new(),
203 branding: branding::AdminBranding::default(),
204 base_path: "/admin".to_string(),
205 dashboard_models: DashboardModelsConfig::default(),
206 dashboard_models_title: "Models".to_string(),
207 dashboard_models_subtitle: None,
208 restore_last_path: true,
209 custom_views: Vec::new(),
210 }
211 }
212}
213
214impl AdminPlugin {
215 /// Register an [`AdminModel`] for one model. Chainable.
216 ///
217 /// If two configs are registered for the same table the last one wins
218 /// (a duplicate registration overwrites the earlier one).
219 ///
220 /// The plugin name defaults to `"admin"` for models registered before
221 /// the plugin is installed into the app. From M7+ plugins will pass
222 /// their own name via `Plugin::admin_register` on the registry.
223 pub fn register(mut self, model: AdminModel) -> Self {
224 self.registry.register("admin", model);
225 self
226 }
227
228 /// Register many [`AdminModel`]s at once — the batch form of
229 /// [`register`](Self::register). Lets each plugin export a
230 /// `Vec<AdminModel>` (its admin surface, declared next to its models)
231 /// and the app register them in one call instead of a `.register(...)`
232 /// per model in `main.rs`.
233 ///
234 /// ```ignore
235 /// // plugins/blog/src/lib.rs
236 /// pub fn admin_models() -> Vec<umbral_admin::AdminModel> {
237 /// vec![post_admin(), comment_admin(), tag_admin()]
238 /// }
239 ///
240 /// // main.rs
241 /// AdminPlugin::default().register_many(blog::admin_models())
242 /// ```
243 pub fn register_many(mut self, models: impl IntoIterator<Item = AdminModel>) -> Self {
244 for model in models {
245 self = self.register(model);
246 }
247 self
248 }
249
250 /// Register an [`AdminModel`] for a specific plugin name.
251 ///
252 /// This is the method the `Plugin::routes` / `on_ready` pathway uses
253 /// when a plugin contributes its own admin registrations. The sidebar
254 /// groups models by the `plugin_name` supplied here.
255 pub fn register_for(mut self, plugin_name: &str, model: AdminModel) -> Self {
256 self.registry.register(plugin_name, model);
257 self
258 }
259
260 /// Batch form of [`register_for`](Self::register_for) — register many
261 /// models under one plugin name (the `Plugin`-pathway batch entry).
262 pub fn register_for_many(
263 mut self,
264 plugin_name: &str,
265 models: impl IntoIterator<Item = AdminModel>,
266 ) -> Self {
267 for model in models {
268 self = self.register_for(plugin_name, model);
269 }
270 self
271 }
272
273 /// Register a dashboard widget. Chainable.
274 ///
275 /// # Example
276 ///
277 /// ```rust,ignore
278 /// use umbral_admin::{AdminPlugin, Widget, WidgetKind, WidgetDataFn, WidgetPayload, KpiPayload, Span};
279 ///
280 /// AdminPlugin::default()
281 /// .register_widget(Widget {
282 /// key: "total_posts",
283 /// title: "Total Posts".to_string(),
284 /// kind: WidgetKind::Kpi,
285 /// default_span: Span { cols: 3, rows: 1 },
286 /// permission: None,
287 /// data: WidgetDataFn::new(|_user| async move {
288 /// WidgetPayload::Kpi(KpiPayload {
289 /// value: "0".to_string(),
290 /// unit: None, delta: None, sparkline: None,
291 /// })
292 /// }),
293 /// });
294 /// ```
295 pub fn register_widget(mut self, widget: Widget) -> Self {
296 self.widget_catalog.push(widget);
297 self
298 }
299
300 /// Override the admin site title — shown in the browser tab,
301 /// the sidebar header, and the login page.
302 ///
303 /// ```ignore
304 /// AdminPlugin::default().site_title("Acme Backoffice")
305 /// ```
306 pub fn site_title(mut self, title: impl Into<String>) -> Self {
307 self.branding.site_title = title.into();
308 self
309 }
310
311 /// One-line description shown on the dashboard / login page
312 /// underneath the site title.
313 pub fn site_description(mut self, description: impl Into<String>) -> Self {
314 self.branding.site_description = description.into();
315 self
316 }
317
318 /// Override the brand primary color. Accepts any valid CSS color
319 /// (`#5b5bd6`, `rgb(91 91 214)`, `hsl(240 60% 60%)`). The wrapper
320 /// template emits a `<style>` that re-assigns `--primary` and
321 /// `--primary-container` so every "primary"-tinted element across
322 /// the admin picks it up automatically.
323 pub fn brand_color(mut self, color: impl Into<String>) -> Self {
324 self.branding.brand_color = color.into();
325 self
326 }
327
328 /// Gap 107: mount the admin at a path other than the default
329 /// `/admin`. Useful when a single domain hosts multiple umbral
330 /// admins, or when the operations team enforces a different
331 /// vanity URL. Accepts `"/myadmin"`, `"myadmin"`, or
332 /// `"/myadmin/"` — all normalise to `"/myadmin"`.
333 ///
334 /// ```ignore
335 /// AdminPlugin::default().at("/backoffice")
336 /// // → routes mount at /backoffice/login, /backoffice/{table}/, ...
337 /// ```
338 ///
339 /// Templates read the configured base via the `admin_base`
340 /// Jinja global, so cross-page links resolve to the new path
341 /// automatically. Handler-side redirects and `sanitise_next`
342 /// also use the configured base.
343 pub fn at(mut self, path: impl Into<String>) -> Self {
344 let raw = path.into();
345 let trimmed = raw.trim_matches('/');
346 self.base_path = if trimmed.is_empty() {
347 String::new()
348 } else {
349 format!("/{trimmed}")
350 };
351 self
352 }
353
354 /// The normalised admin base path. Public so plugin authors and
355 /// the OpenAPI plugin can reference it.
356 pub fn base_path(&self) -> &str {
357 &self.base_path
358 }
359
360 /// Hide the dashboard's "Models" cards section entirely. Use
361 /// when the operator's primary view is widget-driven and a
362 /// long model grid would be noise (200-model enterprise
363 /// installs, single-purpose admins, etc.).
364 ///
365 /// ```ignore
366 /// AdminPlugin::default().dashboard_models_hidden()
367 /// ```
368 pub fn dashboard_models_hidden(mut self) -> Self {
369 self.dashboard_models = DashboardModelsConfig::Hidden;
370 self
371 }
372
373 /// Show only a curated subset of models on the dashboard, in
374 /// the given order. Unknown table names are dropped silently
375 /// (typo-safe — if one plugin is unregistered the rest still
376 /// render).
377 ///
378 /// ```ignore
379 /// AdminPlugin::default().dashboard_models_only(&[
380 /// "product", "order", "customer",
381 /// ])
382 /// ```
383 ///
384 /// Type-safe alternative coming in a follow-up: a
385 /// `models![Product, Order, Customer]` macro that resolves
386 /// each type to its `Model::TABLE` so a rename in the
387 /// struct doesn't require updating string references here.
388 pub fn dashboard_models_only<S: Into<String> + Clone>(mut self, tables: &[S]) -> Self {
389 self.dashboard_models =
390 DashboardModelsConfig::Only(tables.iter().cloned().map(Into::into).collect());
391 self
392 }
393
394 /// Explicit reset to the default — show every registered
395 /// model. Useful when a wrapper builder has previously
396 /// configured a subset / hidden and you want the full grid
397 /// back.
398 pub fn dashboard_models_all(mut self) -> Self {
399 self.dashboard_models = DashboardModelsConfig::All;
400 self
401 }
402
403 /// Append a named widget section to the dashboard. Sections
404 /// render in registration order, each with its own heading
405 /// + (optional) subtitle + widget grid:
406 ///
407 /// ```ignore
408 /// AdminPlugin::default()
409 /// .dashboard_section(
410 /// WidgetSection::new("Sales overview")
411 /// .subtitle("Daily KPIs across the storefront")
412 /// .widget(shop_total_sales_widget())
413 /// .widget(shop_orders_widget()))
414 /// .dashboard_section(
415 /// WidgetSection::new("Engagement")
416 /// .widget(umbral_admin::builtin_recent_users_widget()))
417 /// ```
418 ///
419 /// Widgets registered via the legacy `register_widget(...)`
420 /// land in an implicit final section titled "Widgets" so
421 /// pre-existing apps keep working without refactor.
422 pub fn dashboard_section(mut self, section: WidgetSection) -> Self {
423 self.dashboard_sections.push(section);
424 self
425 }
426
427 /// Insert a section at a specific position in the dashboard.
428 /// Useful when a wrapper builder appended sections earlier
429 /// and you want a new one above them. `index` is clamped at
430 /// the current section count, so `usize::MAX` is equivalent
431 /// to [`Self::dashboard_section`].
432 ///
433 /// ```ignore
434 /// AdminPlugin::default()
435 /// .dashboard_section(sales_section)
436 /// .dashboard_section(system_section)
437 /// // Slot a new section between the two:
438 /// .dashboard_section_at(1, alerts_section)
439 /// ```
440 pub fn dashboard_section_at(mut self, index: usize, section: WidgetSection) -> Self {
441 let i = index.min(self.dashboard_sections.len());
442 self.dashboard_sections.insert(i, section);
443 self
444 }
445
446 /// Override the heading shown above the model-cards section.
447 /// Default "Models". Pair with `dashboard_models_subtitle`
448 /// for a one-line explainer.
449 pub fn dashboard_models_title(mut self, title: impl Into<String>) -> Self {
450 self.dashboard_models_title = title.into();
451 self
452 }
453
454 /// Optional one-line caption under the model-cards heading.
455 pub fn dashboard_models_subtitle(mut self, subtitle: impl Into<String>) -> Self {
456 self.dashboard_models_subtitle = Some(subtitle.into());
457 self
458 }
459
460 /// Control whether the admin "restore where I left off" feature is
461 /// active (default: **`true`** — on by default, opt out to disable).
462 ///
463 /// When enabled (`true`, the default):
464 /// - `/admin/` 302-redirects to the last-visited changelist URL
465 /// stored in `admin_user_pref.preferences.last_path`.
466 /// - The changelist handler writes `last_path` on every page visit.
467 /// - The "Home" breadcrumb carries `?dashboard=1` so the dashboard
468 /// is reachable in one click (the escape hatch becomes a UI affordance).
469 ///
470 /// When disabled (`false`):
471 /// - `/admin/` always renders the dashboard directly.
472 /// - The changelist handler skips the `last_path` write — no dead
473 /// data accumulates in `admin_user_pref.preferences`.
474 ///
475 /// ```ignore
476 /// AdminPlugin::default().restore_last_path(false)
477 /// ```
478 pub fn restore_last_path(mut self, enabled: bool) -> Self {
479 self.restore_last_path = enabled;
480 self
481 }
482
483 /// Register a custom admin view — a widget page mounted at
484 /// `{admin_base}/{view.path}`. Chainable.
485 ///
486 /// ```ignore
487 /// AdminPlugin::default().view(
488 /// AdminView::new("reports/sales", "Sales report")
489 /// .with_icon("bar-chart")
490 /// .section(WidgetSection::new("This month").widget(revenue_kpi())),
491 /// )
492 /// ```
493 pub fn view(mut self, view: AdminView) -> Self {
494 self.custom_views.push(view);
495 self
496 }
497
498 /// Batch form of [`view`](Self::view).
499 pub fn views(mut self, views: impl IntoIterator<Item = AdminView>) -> Self {
500 self.custom_views.extend(views);
501 self
502 }
503
504 /// gaps3 #7 — custom views whose path is safe to mount, with the rest
505 /// dropped (and logged). Two views registered at the same path would
506 /// make axum's router `panic!` on a route conflict at boot; rejecting
507 /// the duplicate here turns that into a clear `tracing::error!` and
508 /// keeps the rest of the admin serving (the rejected view is absent
509 /// from the router AND the sidebar, both of which read this list).
510 ///
511 /// Views mount under the dedicated `/custom-views/` URL namespace (see
512 /// `Plugin::routes`), which is hyphenated and therefore can never be a
513 /// model table name (tables are snake_case) — so a view can NOT collide
514 /// with a built-in admin route or shadow a changelist. That's why the
515 /// only checks here are empty-path and duplicate-path.
516 fn resolved_custom_views(&self) -> Vec<AdminView> {
517 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
518 let mut out = Vec::with_capacity(self.custom_views.len());
519 for v in &self.custom_views {
520 let path = v.path();
521 if path.is_empty() {
522 tracing::error!(title = v.title(), "admin custom view rejected: empty path");
523 continue;
524 }
525 if !seen.insert(path) {
526 tracing::error!(path, "admin custom view rejected: duplicate path");
527 continue;
528 }
529 out.push(v.clone());
530 }
531 out
532 }
533}
534
535/// Shared state injected into every route via [`axum::extract::State`].
536///
537/// `Arc` makes the clone cheap; the registry is immutable after `build()`.
538#[derive(Clone, Debug)]
539struct AdminState {
540 registry: Arc<AdminRegistry>,
541 /// Flat widget catalog — every widget across all sections.
542 /// Used by `GET /admin/api/dashboard/widgets/<key>/data` to
543 /// look up by key without knowing which section owns it.
544 widget_catalog: Arc<Vec<Widget>>,
545 /// Dashboard sections in render order. Each carries its own
546 /// title + subtitle + widgets. The implicit "Widgets" section
547 /// (from legacy `register_widget(...)` calls) lives at the end.
548 dashboard_sections: Arc<Vec<WidgetSection>>,
549 /// Dashboard model-cards section config. Read by the
550 /// dashboard handler to filter (or skip) the model grid.
551 dashboard_models: DashboardModelsConfig,
552 /// Heading + optional subtitle for the model-cards section.
553 dashboard_models_title: String,
554 dashboard_models_subtitle: Option<String>,
555 /// gaps2 #33 — mirrors `AdminPlugin::restore_last_path`. The index
556 /// handler reads this to decide whether to redirect; the list handler
557 /// reads it to decide whether to write `last_path`.
558 restore_last_path: bool,
559 /// Developer-registered custom views, for the page handler + sidebar.
560 custom_views: Arc<Vec<AdminView>>,
561 /// Gate map: widget key → view permission codename, for every widget
562 /// that belongs to a `.with_permission()`-gated custom view.
563 ///
564 /// Built at `routes()` time from the custom-view registration list and
565 /// checked by `dashboard_widget_data` after `require_staff` — if the
566 /// widget's key is present, the requesting user must also hold the mapped
567 /// codename, or the endpoint returns 403.
568 ///
569 /// Dashboard widgets and widgets in ungated views are NOT in this map,
570 /// so the gate only applies to views that explicitly opt in via
571 /// `.with_permission(...)`.
572 widget_gates: Arc<std::collections::HashMap<String, String>>,
573}
574
575impl AdminState {
576 fn config_for(&self, table: &str) -> Option<&AdminConfig> {
577 self.registry.get(table).map(|r| &r.model)
578 }
579}
580
581/// Gap 107 — join an admin sub-path with the configured base.
582///
583/// `route("/login", "/admin")` → `"/admin/login"`. Used at routes()
584/// construction so every `.route(...)` call honours the
585/// `AdminPlugin::at()` override without hardcoding `/admin` anywhere.
586/// An empty `sub` (the index page) returns the base path itself, so
587/// `route("", "/admin")` → `"/admin"` and not `"/admin/"`.
588fn route(sub: &str, base: &str) -> String {
589 if sub.is_empty() {
590 return base.to_string();
591 }
592 format!("{base}{sub}")
593}
594
595impl Plugin for AdminPlugin {
596 fn name(&self) -> &'static str {
597 "admin"
598 }
599
600 fn dependencies(&self) -> &'static [&'static str] {
601 // Auth is required: login verifies credentials via umbral-auth.
602 // Sessions is required: login creates sessions.
603 &["auth", "sessions"]
604 }
605
606 fn static_files(&self) -> Vec<umbral::plugin::StaticFile> {
607 admin_static_files()
608 }
609
610 fn static_dirs(&self) -> Vec<umbral::plugin::StaticDir> {
611 // The admin ships its assets EMBEDDED (see `static_files()`), so it
612 // works with zero config. This `static_dirs()` entry additionally
613 // exposes the on-disk source so `collect_static` can gather the
614 // admin's `admin.css` / `admin.js` into `<static_root>/admin/` for
615 // CDN / disk serving. Both modes coexist: the embedded specific
616 // route wins in-binary, the collected files serve when a deployment
617 // customises `static_url` or fronts assets with a CDN.
618 //
619 // Because the embedded specific route shadows the pipeline in-binary,
620 // live-editing admin.css won't hot-reload — acceptable for these
621 // framework-internal assets.
622 let source_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
623 .join("src")
624 .join("assets");
625 vec![umbral::plugin::StaticDir::new("admin", source_dir)]
626 }
627
628 fn models(&self) -> Vec<umbral::migrate::ModelMeta> {
629 vec![
630 umbral::migrate::ModelMeta::for_::<crate::models::AdminUserPref>(),
631 umbral::migrate::ModelMeta::for_::<crate::models::AdminAuditLog>(),
632 ]
633 }
634
635 fn routes(&self) -> Router {
636 // Seal the developer-configured branding into the global so
637 // the template engine picks it up on first init. Subsequent
638 // attempts to set it are silent no-ops; the typical flow is
639 // exactly one Plugin::routes() call per process.
640 //
641 // Gap 107: the configured `base_path` rides along with the
642 // branding so templates and handlers read it from one place.
643 // gaps2 #33: `restore_last_path` joins the branding cell so
644 // templates can query the flag (e.g. to emit `?dashboard=1`
645 // on the "Home" breadcrumb link) without a handler pass-through.
646 let mut sealed_branding = self.branding.clone();
647 sealed_branding.base_path = self.base_path.clone();
648 sealed_branding.restore_last_path = self.restore_last_path;
649 let _ = branding::BRANDING.set(sealed_branding);
650
651 // Final section list: developer-declared sections first
652 // (preserving registration order), then an implicit
653 // "Widgets" section at the end containing any legacy
654 // `register_widget(...)` calls. Apps that exclusively
655 // use the new `.dashboard_section(...)` API end up with
656 // a clean sectioned dashboard; apps that only use the
657 // legacy call see one un-sectioned grid like before;
658 // mixed-mode apps see explicit sections first and a
659 // catch-all at the bottom.
660 let mut sections: Vec<WidgetSection> = self.dashboard_sections.clone();
661 if !self.widget_catalog.is_empty() {
662 sections
663 .push(WidgetSection::new("Widgets").widgets(self.widget_catalog.iter().cloned()));
664 }
665 // Flat catalog — feeds the per-widget data API. Built by
666 // flattening every section so a single lookup-by-key
667 // works regardless of which section a widget lives in.
668 let mut catalog: Vec<Widget> = sections
669 .iter()
670 .flat_map(|s| s.widgets.iter().cloned())
671 .collect();
672
673 // Custom-view widgets join the same flat catalog so the per-key
674 // data endpoint resolves them unchanged. Keys are global → warn on dups.
675 // The gate map is built alongside the catalog: for every widget in a
676 // permission-gated view (`.with_permission(codename)`), record
677 // `widget_key → codename` so `dashboard_widget_data` can enforce the
678 // same codename check on the API call, not just on the page load.
679 // gaps3 #7: validate custom-view paths ONCE, up front. Everything
680 // downstream (widget flatten, gate map, sidebar state, route mount)
681 // reads this resolved list so a rejected view is absent everywhere.
682 let resolved_views = self.resolved_custom_views();
683 let mut seen_keys: std::collections::HashSet<&str> =
684 catalog.iter().map(|w| w.key).collect();
685 let mut widget_gates: std::collections::HashMap<String, String> =
686 std::collections::HashMap::new();
687 for v in &resolved_views {
688 for w in v.sections().iter().flat_map(|s| s.widgets.iter()) {
689 if !seen_keys.insert(w.key) {
690 tracing::warn!(
691 widget_key = w.key,
692 view = v.path(),
693 "duplicate widget key across dashboard/custom views; \
694 the data endpoint resolves the first match"
695 );
696 }
697 catalog.push(w.clone());
698 // Only gated views contribute to the gate map.
699 if let Some(perm) = v.permission() {
700 widget_gates.insert(w.key.to_string(), perm.to_string());
701 }
702 }
703 }
704
705 let state = AdminState {
706 registry: Arc::new(self.registry.clone()),
707 widget_catalog: Arc::new(catalog),
708 dashboard_sections: Arc::new(sections),
709 dashboard_models: self.dashboard_models.clone(),
710 dashboard_models_title: self.dashboard_models_title.clone(),
711 dashboard_models_subtitle: self.dashboard_models_subtitle.clone(),
712 restore_last_path: self.restore_last_path,
713 custom_views: Arc::new(resolved_views.clone()),
714 widget_gates: Arc::new(widget_gates),
715 };
716 let mut router = Router::new()
717 // Login / logout (no auth required)
718 .route(
719 &route("/login", &self.base_path),
720 axum::routing::get(login_get).post(login_post),
721 )
722 .route(
723 &route("/logout", &self.base_path),
724 axum::routing::get(logout_handler),
725 )
726 // Index + CRUD routes (all require staff session)
727 .route(
728 &route("", &self.base_path),
729 axum::routing::get(handlers::list::index),
730 )
731 .route(
732 &route("/", &self.base_path),
733 axum::routing::get(handlers::list::index),
734 )
735 .route(
736 &route("/{table}/", &self.base_path),
737 axum::routing::get(handlers::list::list),
738 )
739 .route(
740 &route("/{table}/new", &self.base_path),
741 axum::routing::get(handlers::crud::new_form).post(handlers::crud::create),
742 )
743 .route(
744 &route("/{table}/action", &self.base_path),
745 post(handlers::actions::run_action),
746 )
747 // Phase 2: fragment-only rows endpoint (search/sort/filter/paginate)
748 .route(
749 &route("/{table}/rows", &self.base_path),
750 axum::routing::get(handlers::list::rows_fragment),
751 )
752 // gaps2 #11 round 2: toggle a column's visibility on
753 // the persisted per-table prefs.
754 .route(
755 &route("/{table}/columns/{column}/toggle", &self.base_path),
756 post(handlers::list::toggle_column_visibility),
757 )
758 // Filter dialog fragment
759 .route(
760 &route("/{table}/filter-dialog", &self.base_path),
761 axum::routing::get(handlers::list::filter_dialog_handler),
762 )
763 // Phase 2: new-record sheet (create mode)
764 .route(
765 &route("/{table}/new-sheet", &self.base_path),
766 axum::routing::get(handlers::sheet::new_sheet),
767 )
768 // Phase 2: delete confirm dialog fragment
769 .route(
770 &route("/{table}/{id}/_confirm-delete", &self.base_path),
771 axum::routing::get(handlers::sheet::confirm_delete_dialog),
772 )
773 // Phase 2: sheet fragments (preview + edit)
774 .route(
775 &route("/{table}/{id}/sheet", &self.base_path),
776 axum::routing::get(handlers::sheet::preview_sheet),
777 )
778 .route(
779 &route("/{table}/{id}/edit-sheet", &self.base_path),
780 axum::routing::get(handlers::sheet::edit_sheet_handler),
781 )
782 .route(
783 &route("/{table}/{id}", &self.base_path),
784 axum::routing::get(handlers::crud::detail),
785 )
786 .route(
787 &route("/{table}/{id}/edit", &self.base_path),
788 axum::routing::get(handlers::crud::edit_form).post(handlers::crud::update),
789 )
790 // Phase 2: create via sheet (POST)
791 .route(
792 &route("/{table}/create", &self.base_path),
793 axum::routing::post(handlers::sheet::sheet_create),
794 )
795 // Phase 2: DELETE method for HTMX delete button
796 .route(
797 &route("/{table}/{id}", &self.base_path),
798 axum::routing::delete(handlers::crud::htmx_delete),
799 )
800 .route(
801 &route("/{table}/{id}/delete", &self.base_path),
802 post(handlers::crud::delete),
803 )
804 // Phase 3: per-key action dispatch
805 .route(
806 &route("/{table}/actions/{key}", &self.base_path),
807 axum::routing::post(handlers::actions::dispatch_action),
808 )
809 // Phase 3: FK/M2M async picker endpoints
810 .route(
811 &route("/api/{table}/{field}/options/resolve", &self.base_path),
812 axum::routing::get(handlers::fk_picker::fk_options_resolve),
813 )
814 .route(
815 &route("/api/{table}/{field}/options", &self.base_path),
816 axum::routing::get(handlers::fk_picker::fk_options),
817 )
818 // Phase 3: inline cell edit
819 .route(
820 &route("/{table}/{id}/cell/{field}/edit", &self.base_path),
821 axum::routing::get(handlers::inline_edit::cell_edit_get),
822 )
823 .route(
824 &route("/{table}/{id}/cell/{field}", &self.base_path),
825 axum::routing::post(handlers::inline_edit::cell_edit_post),
826 )
827 // Password change for models with password_field set
828 .route(
829 &route("/{table}/{id}/change-password", &self.base_path),
830 axum::routing::post(handlers::sheet::change_password_handler),
831 )
832 // Phase 4: user prefs
833 .route(
834 &route("/api/prefs", &self.base_path),
835 axum::routing::get(handlers::prefs::get_prefs_handler)
836 .put(handlers::prefs::put_prefs_handler),
837 )
838 // Phase 4: audit history
839 .route(
840 &route("/{table}/{id}/history", &self.base_path),
841 axum::routing::get(handlers::history::history_handler),
842 )
843 // Phase 4: dashboard
844 .route(
845 &route("/api/dashboard/catalog", &self.base_path),
846 axum::routing::get(handlers::dashboard::dashboard_catalog),
847 )
848 .route(
849 &route("/api/dashboard/layout", &self.base_path),
850 axum::routing::get(handlers::dashboard::dashboard_layout_get)
851 .put(handlers::dashboard::dashboard_layout_put),
852 )
853 .route(
854 &route("/api/dashboard/widgets/{key}/data", &self.base_path),
855 axum::routing::get(handlers::dashboard::dashboard_widget_data),
856 )
857 // gaps2 #36: EasyMDE markdown-editor image upload. Staff-gated
858 // (no `{table}` — a media upload isn't scoped to one model), and
859 // stores through the ambient `umbral::storage` seam. Returns
860 // `{ "url": ... }` for the editor's `imageUploadFunction`.
861 .route(
862 &route("/upload-image", &self.base_path),
863 post(handlers::upload::upload_image),
864 )
865 // Phase 4: command palette fragment + global record search
866 .route(
867 &route("/api/palette", &self.base_path),
868 axum::routing::get(handlers::palette::palette_fragment),
869 )
870 .route(
871 &route("/api/palette/search", &self.base_path),
872 axum::routing::get(handlers::palette::palette_search),
873 )
874 // Static admin.css is mounted by the framework via
875 // `static_files()` — no manual route needed here.
876 ;
877 // Mount one GET route per registered custom view at
878 // `{base}/{view.path}`. The per-invocation `slug` clone keeps the
879 // handler `Clone` (axum requires the handler future factory to be
880 // cloneable across concurrent requests).
881 for v in &resolved_views {
882 let slug = v.path().to_string();
883 let full = route(&format!("/custom-views/{}/", v.path()), &self.base_path);
884 router = router.route(
885 &full,
886 axum::routing::get({
887 let slug = slug.clone();
888 move |state: axum::extract::State<AdminState>,
889 headers: axum::http::HeaderMap| {
890 let slug = slug.clone();
891 async move {
892 crate::handlers::custom_view::custom_view(state, headers, slug).await
893 }
894 }
895 }),
896 );
897 }
898 router.with_state(state)
899 }
900
901 fn route_paths(&self) -> Vec<umbral::routes::RouteSpec> {
902 // Companion list to `routes()` — surfaced by the dev-mode
903 // default 404 page. Each entry pairs a path pattern with the
904 // HTTP methods it accepts; keep in sync with the `.route(...)`
905 // calls above. Mismatch is "stale route list," not a routing
906 // bug.
907 use umbral::routes::RouteSpec;
908 // Method shorthands — each constructed once and `clone()`d per
909 // entry so the source list stays one-line-per-route.
910 let g = || vec!["GET"];
911 let p = || vec!["POST"];
912 let gp = || vec!["GET", "POST"];
913 let gpd = || vec!["GET", "POST", "DELETE"];
914 let gput = || vec!["GET", "PUT"];
915 let mut specs = vec![
916 RouteSpec::new(&route("", &self.base_path), g()),
917 RouteSpec::new(&route("/", &self.base_path), g()),
918 RouteSpec::new(&route("/login", &self.base_path), gp()),
919 RouteSpec::new(&route("/logout", &self.base_path), g()),
920 RouteSpec::new(&route("/{table}/", &self.base_path), g()),
921 RouteSpec::new(&route("/{table}/new", &self.base_path), gp()),
922 RouteSpec::new(&route("/{table}/action", &self.base_path), p()),
923 RouteSpec::new(&route("/{table}/rows", &self.base_path), g()),
924 RouteSpec::new(&route("/{table}/filter-dialog", &self.base_path), g()),
925 RouteSpec::new(&route("/{table}/new-sheet", &self.base_path), g()),
926 RouteSpec::new(&route("/{table}/create", &self.base_path), p()),
927 RouteSpec::new(&route("/{table}/{id}", &self.base_path), gpd()),
928 RouteSpec::new(&route("/{table}/{id}/edit", &self.base_path), gp()),
929 RouteSpec::new(&route("/{table}/{id}/edit-sheet", &self.base_path), g()),
930 RouteSpec::new(&route("/{table}/{id}/sheet", &self.base_path), g()),
931 RouteSpec::new(&route("/{table}/{id}/delete", &self.base_path), p()),
932 RouteSpec::new(
933 &route("/{table}/{id}/_confirm-delete", &self.base_path),
934 g(),
935 ),
936 RouteSpec::new(&route("/{table}/{id}/history", &self.base_path), g()),
937 RouteSpec::new(
938 &route("/{table}/{id}/change-password", &self.base_path),
939 p(),
940 ),
941 RouteSpec::new(&route("/{table}/{id}/cell/{field}", &self.base_path), p()),
942 RouteSpec::new(
943 &route("/{table}/{id}/cell/{field}/edit", &self.base_path),
944 g(),
945 ),
946 RouteSpec::new(&route("/{table}/actions/{key}", &self.base_path), p()),
947 RouteSpec::new(&route("/api/{table}/{field}/options", &self.base_path), g()),
948 RouteSpec::new(
949 &route("/api/{table}/{field}/options/resolve", &self.base_path),
950 g(),
951 ),
952 RouteSpec::new(&route("/api/prefs", &self.base_path), gput()),
953 RouteSpec::new(&route("/upload-image", &self.base_path), p()),
954 RouteSpec::new(&route("/api/palette", &self.base_path), g()),
955 RouteSpec::new(&route("/api/palette/search", &self.base_path), g()),
956 RouteSpec::new(&route("/api/dashboard/catalog", &self.base_path), g()),
957 RouteSpec::new(&route("/api/dashboard/layout", &self.base_path), gput()),
958 RouteSpec::new(
959 &route("/api/dashboard/widgets/{key}/data", &self.base_path),
960 g(),
961 ),
962 ];
963 // Companion entries for the developer-registered custom views,
964 // mounted in `routes()` as `GET {base}/{view.path}`.
965 for v in &self.resolved_custom_views() {
966 specs.push(RouteSpec::new(
967 &format!("{}/custom-views/{}/", self.base_path, v.path()),
968 g(),
969 ));
970 }
971 specs
972 }
973
974 fn on_ready(
975 &self,
976 _ctx: &umbral::plugin::AppContext,
977 ) -> Result<(), umbral::plugin::PluginError> {
978 // Tables are produced by the migration engine off
979 // `Self::models()` — same path as every other plugin's models.
980 // No bootstrap DDL here.
981
982 // CSRF posture (audit_2 admin #5). The admin's mutating handlers
983 // (create / update / delete / bulk-action / inline-edit / upload /
984 // prefs) do NOT self-verify a CSRF token — only `login_post` does — so
985 // cross-site request forgery is defended by the session cookie's
986 // `SameSite` attribute. `SameSite=Lax` (the default) already blocks the
987 // forged cross-site POST/PUT/DELETE that would carry the session
988 // cookie. If an operator sets `SameSite=None` (e.g. to serve a
989 // cross-origin SPA) that defense is gone, so the admin's mutations are
990 // CSRF-forgeable unless a CSRF middleware is mounted. `on_ready` runs in
991 // topological order and the admin depends on `sessions`, so the sealed
992 // value is readable here. Warn loudly rather than fail (a cross-origin
993 // API with a properly-mounted CSRF layer is a legitimate setup).
994 if umbral_sessions::configured_same_site() == umbral_sessions::SameSite::None {
995 tracing::warn!(
996 "umbral-admin: the session cookie is SameSite=None, which removes the \
997 cross-site-request CSRF defense the admin's mutating handlers rely on. \
998 Mount a CSRF middleware (umbral-security's SecurityPlugin) so admin \
999 create/update/delete/upload/prefs actions can't be forged cross-site, \
1000 or keep the session cookie at SameSite=Lax/Strict for same-origin admin use."
1001 );
1002 }
1003 Ok(())
1004 }
1005}
1006
1007// =========================================================================
1008// Sidebar context helpers.
1009//
1010// Every handler that renders the authenticated shell calls `sidebar_apps`
1011// to pass the nav tree into the template.
1012// =========================================================================
1013
1014// =========================================================================
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::*;
1019
1020 #[test]
1021 fn admin_model_defaults() {
1022 let m = AdminModel::new("post");
1023 assert_eq!(m.get_list_per_page(), 25);
1024 assert!(m.inlines.is_empty());
1025 assert!(m.label.is_none());
1026 assert!(m.icon.is_none());
1027 }
1028
1029 #[test]
1030 fn admin_config_alias_compiles() {
1031 // The type alias must be identical to AdminModel at the Rust level.
1032 let _: AdminConfig = AdminModel::new("test");
1033 }
1034
1035 #[test]
1036 fn static_files_use_unified_static_url() {
1037 // The embedded admin assets now mount on the unified `/static/admin/…`
1038 // pipeline URL (default `static_url`), not the legacy `/admin/static/…`.
1039 let files = AdminPlugin::default().static_files();
1040 let paths: Vec<&str> = files.iter().map(|f| f.url_path).collect();
1041 assert!(
1042 paths.contains(&"/static/admin/admin.css"),
1043 "admin.css should mount at /static/admin/admin.css, got {paths:?}"
1044 );
1045 assert!(
1046 paths.contains(&"/static/admin/admin.js"),
1047 "admin.js should mount at /static/admin/admin.js, got {paths:?}"
1048 );
1049 // Both still ship non-trivial embedded bytes (zero-config preserved).
1050 for f in &files {
1051 assert!(
1052 f.body.len() > 100,
1053 "{} should ship embedded bytes, got {} bytes",
1054 f.url_path,
1055 f.body.len()
1056 );
1057 }
1058 }
1059
1060 #[test]
1061 fn static_dirs_maps_admin_namespace_to_existing_assets_dir() {
1062 let dirs = AdminPlugin::default().static_dirs();
1063 assert_eq!(dirs.len(), 1, "admin contributes exactly one static dir");
1064 let dir = &dirs[0];
1065 assert_eq!(dir.namespace, "admin");
1066 // The source dir actually exists on disk and holds the css/js the
1067 // embedded route serves — so `collect_static` has real files to gather.
1068 assert!(
1069 dir.source_dir.join("admin.css").is_file(),
1070 "{} should contain admin.css",
1071 dir.source_dir.display()
1072 );
1073 assert!(
1074 dir.source_dir.join("admin.js").is_file(),
1075 "{} should contain admin.js",
1076 dir.source_dir.display()
1077 );
1078 }
1079}
1080
1081#[cfg(test)]
1082mod custom_view_wiring_tests {
1083 use super::*;
1084 use crate::views::AdminView;
1085 use crate::widgets::{
1086 KpiPayload, Widget, WidgetDataFn, WidgetKind, WidgetPayload, WidgetSection,
1087 };
1088
1089 fn tiny_kpi(key: &'static str) -> Widget {
1090 Widget {
1091 key,
1092 title: "T".into(),
1093 kind: WidgetKind::Kpi,
1094 default_span: Default::default(),
1095 permission: None,
1096 data: WidgetDataFn::new(|_user| async {
1097 WidgetPayload::Kpi(KpiPayload {
1098 value: "0".into(),
1099 unit: None,
1100 delta: None,
1101 sparkline: None,
1102 })
1103 }),
1104 default_period: None,
1105 }
1106 }
1107
1108 // gaps3 #7 — a DUPLICATE view path is dropped (logged), not panicked.
1109 // Views mount under the /custom-views/ namespace, so a path that looks
1110 // like a built-in route ("login") or a table can't collide — only an
1111 // exact duplicate path would make axum's router panic at boot.
1112 #[test]
1113 fn resolved_custom_views_drops_duplicate_paths() {
1114 let plugin = AdminPlugin::default()
1115 .view(AdminView::new("reports/sales", "A"))
1116 .view(AdminView::new("reports/sales", "dup B")) // duplicate → dropped
1117 .view(AdminView::new("login", "safe under the namespace")) // /custom-views/login/ — no collision
1118 .view(AdminView::new("reports/ok", "C")); // valid, distinct
1119
1120 let resolved = plugin.resolved_custom_views();
1121 let paths: Vec<&str> = resolved.iter().map(|v| v.path()).collect();
1122 assert_eq!(
1123 paths,
1124 vec!["reports/sales", "login", "reports/ok"],
1125 "only the exact duplicate is dropped (first wins); a 'login' path is fine under /custom-views/"
1126 );
1127
1128 // The real regression: routes() must not panic on the duplicate
1129 // registration now that the resolver drops it first.
1130 let _router = plugin.routes();
1131 }
1132
1133 #[test]
1134 fn view_registers_and_flattens_widgets_into_catalog() {
1135 let plugin = AdminPlugin::default().view(
1136 AdminView::new("reports/sales", "Sales")
1137 .section(WidgetSection::new("S").widget(tiny_kpi("rpt_sales_total"))),
1138 );
1139 // The view is stored on the plugin.
1140 assert_eq!(plugin.custom_views.len(), 1);
1141 assert_eq!(plugin.custom_views[0].path(), "reports/sales");
1142
1143 // The same flatten the `routes()` builder performs: a registered
1144 // view's widgets become reachable in the global key catalog so the
1145 // per-key data endpoint resolves them unchanged.
1146 let catalog_keys: Vec<&str> = plugin
1147 .custom_views
1148 .iter()
1149 .flat_map(|v| v.sections().iter())
1150 .flat_map(|s| s.widgets.iter())
1151 .map(|w| w.key)
1152 .collect();
1153 assert!(
1154 catalog_keys.contains(&"rpt_sales_total"),
1155 "the view's widget key should be flattenable into the catalog, got {catalog_keys:?}"
1156 );
1157 }
1158}