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;
59pub mod 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, FilterOption, HeatmapCell, HeatmapPayload, HeatmapRow, KpiPayload, LinePayload,
96 ProgressItem, ProgressPayload, RadialPayload, RadialTrack, Series, Span, TableColumn,
97 TablePayload, Widget, WidgetDataFn, WidgetFilter, WidgetFilterKind, WidgetInstance, WidgetKind,
98 WidgetParams, WidgetPayload, WidgetSection, 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 /// Show or hide the version string in the admin sidebar and on the login page
319 /// (gaps3 #67).
320 ///
321 /// ```ignore
322 /// AdminPlugin::default().show_version(false)
323 /// ```
324 ///
325 /// On by default, showing umbral's own version. Turning it off is reasonable: an
326 /// admin is a private surface, and telling every visitor which framework version you
327 /// run is free reconnaissance for anyone matching it against a CVE list.
328 /// The resolved branding, for tests. Not part of the stable surface.
329 #[doc(hidden)]
330 pub fn branding_for_tests(&self) -> &branding::AdminBranding {
331 &self.branding
332 }
333
334 pub fn show_version(mut self, show: bool) -> Self {
335 self.branding.version_label = if show {
336 Some(
337 self.branding
338 .version_label
339 .unwrap_or_else(crate::branding::umbral_version_label),
340 )
341 } else {
342 None
343 };
344 self
345 }
346
347 /// Show YOUR version instead of umbral's (gaps3 #67).
348 ///
349 /// ```ignore
350 /// AdminPlugin::default().version(concat!("MyShop v", env!("CARGO_PKG_VERSION")))
351 /// ```
352 ///
353 /// The default advertises the framework — which is what the operator of a shop
354 /// almost certainly does NOT want on their staff login page. Whose version an admin
355 /// shows is a product decision, so it is yours to make. Implies `show_version(true)`.
356 ///
357 /// Prefer `env!("CARGO_PKG_VERSION")` over a literal: a hardcoded version is a lie
358 /// waiting for the next release, which is exactly how the admin came to claim
359 /// `v0.0.1` five releases after it stopped being true.
360 pub fn version(mut self, label: impl Into<String>) -> Self {
361 self.branding.version_label = Some(label.into());
362 self
363 }
364
365 /// Override the brand primary color. Accepts any valid CSS color
366 /// (`#5b5bd6`, `rgb(91 91 214)`, `hsl(240 60% 60%)`). The wrapper
367 /// template emits a `<style>` that re-assigns `--primary` and
368 /// `--primary-container` so every "primary"-tinted element across
369 /// the admin picks it up automatically.
370 pub fn brand_color(mut self, color: impl Into<String>) -> Self {
371 self.branding.brand_color = color.into();
372 self
373 }
374
375 /// Gap 107: mount the admin at a path other than the default
376 /// `/admin`. Useful when a single domain hosts multiple umbral
377 /// admins, or when the operations team enforces a different
378 /// vanity URL. Accepts `"/myadmin"`, `"myadmin"`, or
379 /// `"/myadmin/"` — all normalise to `"/myadmin"`.
380 ///
381 /// ```ignore
382 /// AdminPlugin::default().at("/backoffice")
383 /// // → routes mount at /backoffice/login, /backoffice/{table}/, ...
384 /// ```
385 ///
386 /// Templates read the configured base via the `admin_base`
387 /// Jinja global, so cross-page links resolve to the new path
388 /// automatically. Handler-side redirects and `sanitise_next`
389 /// also use the configured base.
390 pub fn at(mut self, path: impl Into<String>) -> Self {
391 let raw = path.into();
392 let trimmed = raw.trim_matches('/');
393 self.base_path = if trimmed.is_empty() {
394 String::new()
395 } else {
396 format!("/{trimmed}")
397 };
398 self
399 }
400
401 /// The normalised admin base path. Public so plugin authors and
402 /// the OpenAPI plugin can reference it.
403 pub fn base_path(&self) -> &str {
404 &self.base_path
405 }
406
407 /// Hide the dashboard's "Models" cards section entirely. Use
408 /// when the operator's primary view is widget-driven and a
409 /// long model grid would be noise (200-model enterprise
410 /// installs, single-purpose admins, etc.).
411 ///
412 /// ```ignore
413 /// AdminPlugin::default().dashboard_models_hidden()
414 /// ```
415 pub fn dashboard_models_hidden(mut self) -> Self {
416 self.dashboard_models = DashboardModelsConfig::Hidden;
417 self
418 }
419
420 /// Show only a curated subset of models on the dashboard, in
421 /// the given order. Unknown table names are dropped silently
422 /// (typo-safe — if one plugin is unregistered the rest still
423 /// render).
424 ///
425 /// ```ignore
426 /// AdminPlugin::default().dashboard_models_only(&[
427 /// "product", "order", "customer",
428 /// ])
429 /// ```
430 ///
431 /// Type-safe alternative coming in a follow-up: a
432 /// `models![Product, Order, Customer]` macro that resolves
433 /// each type to its `Model::TABLE` so a rename in the
434 /// struct doesn't require updating string references here.
435 pub fn dashboard_models_only<S: Into<String> + Clone>(mut self, tables: &[S]) -> Self {
436 self.dashboard_models =
437 DashboardModelsConfig::Only(tables.iter().cloned().map(Into::into).collect());
438 self
439 }
440
441 /// Explicit reset to the default — show every registered
442 /// model. Useful when a wrapper builder has previously
443 /// configured a subset / hidden and you want the full grid
444 /// back.
445 pub fn dashboard_models_all(mut self) -> Self {
446 self.dashboard_models = DashboardModelsConfig::All;
447 self
448 }
449
450 /// Append a named widget section to the dashboard. Sections
451 /// render in registration order, each with its own heading
452 /// + (optional) subtitle + widget grid:
453 ///
454 /// ```ignore
455 /// AdminPlugin::default()
456 /// .dashboard_section(
457 /// WidgetSection::new("Sales overview")
458 /// .subtitle("Daily KPIs across the storefront")
459 /// .widget(shop_total_sales_widget())
460 /// .widget(shop_orders_widget()))
461 /// .dashboard_section(
462 /// WidgetSection::new("Engagement")
463 /// .widget(umbral_admin::builtin_recent_users_widget()))
464 /// ```
465 ///
466 /// Widgets registered via the legacy `register_widget(...)`
467 /// land in an implicit final section titled "Widgets" so
468 /// pre-existing apps keep working without refactor.
469 pub fn dashboard_section(mut self, section: WidgetSection) -> Self {
470 self.dashboard_sections.push(section);
471 self
472 }
473
474 /// Insert a section at a specific position in the dashboard.
475 /// Useful when a wrapper builder appended sections earlier
476 /// and you want a new one above them. `index` is clamped at
477 /// the current section count, so `usize::MAX` is equivalent
478 /// to [`Self::dashboard_section`].
479 ///
480 /// ```ignore
481 /// AdminPlugin::default()
482 /// .dashboard_section(sales_section)
483 /// .dashboard_section(system_section)
484 /// // Slot a new section between the two:
485 /// .dashboard_section_at(1, alerts_section)
486 /// ```
487 pub fn dashboard_section_at(mut self, index: usize, section: WidgetSection) -> Self {
488 let i = index.min(self.dashboard_sections.len());
489 self.dashboard_sections.insert(i, section);
490 self
491 }
492
493 /// Override the heading shown above the model-cards section.
494 /// Default "Models". Pair with `dashboard_models_subtitle`
495 /// for a one-line explainer.
496 pub fn dashboard_models_title(mut self, title: impl Into<String>) -> Self {
497 self.dashboard_models_title = title.into();
498 self
499 }
500
501 /// Optional one-line caption under the model-cards heading.
502 pub fn dashboard_models_subtitle(mut self, subtitle: impl Into<String>) -> Self {
503 self.dashboard_models_subtitle = Some(subtitle.into());
504 self
505 }
506
507 /// Control whether the admin "restore where I left off" feature is
508 /// active (default: **`true`** — on by default, opt out to disable).
509 ///
510 /// When enabled (`true`, the default):
511 /// - `/admin/` 302-redirects to the last-visited changelist URL
512 /// stored in `admin_user_pref.preferences.last_path`.
513 /// - The changelist handler writes `last_path` on every page visit.
514 /// - The "Home" breadcrumb carries `?dashboard=1` so the dashboard
515 /// is reachable in one click (the escape hatch becomes a UI affordance).
516 ///
517 /// When disabled (`false`):
518 /// - `/admin/` always renders the dashboard directly.
519 /// - The changelist handler skips the `last_path` write — no dead
520 /// data accumulates in `admin_user_pref.preferences`.
521 ///
522 /// ```ignore
523 /// AdminPlugin::default().restore_last_path(false)
524 /// ```
525 pub fn restore_last_path(mut self, enabled: bool) -> Self {
526 self.restore_last_path = enabled;
527 self
528 }
529
530 /// Register a custom admin view — a widget page mounted at
531 /// `{admin_base}/{view.path}`. Chainable.
532 ///
533 /// ```ignore
534 /// AdminPlugin::default().view(
535 /// AdminView::new("reports/sales", "Sales report")
536 /// .with_icon("bar-chart")
537 /// .section(WidgetSection::new("This month").widget(revenue_kpi())),
538 /// )
539 /// ```
540 pub fn view(mut self, view: AdminView) -> Self {
541 self.custom_views.push(view);
542 self
543 }
544
545 /// Batch form of [`view`](Self::view).
546 pub fn views(mut self, views: impl IntoIterator<Item = AdminView>) -> Self {
547 self.custom_views.extend(views);
548 self
549 }
550
551 /// gaps3 #7 — custom views whose path is safe to mount, with the rest
552 /// dropped (and logged). Two views registered at the same path would
553 /// make axum's router `panic!` on a route conflict at boot; rejecting
554 /// the duplicate here turns that into a clear `tracing::error!` and
555 /// keeps the rest of the admin serving (the rejected view is absent
556 /// from the router AND the sidebar, both of which read this list).
557 ///
558 /// Views mount under the dedicated `/custom-views/` URL namespace (see
559 /// `Plugin::routes`), which is hyphenated and therefore can never be a
560 /// model table name (tables are snake_case) — so a view can NOT collide
561 /// with a built-in admin route or shadow a changelist. That's why the
562 /// only checks here are empty-path and duplicate-path.
563 fn resolved_custom_views(&self) -> Vec<AdminView> {
564 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
565 let mut out = Vec::with_capacity(self.custom_views.len());
566 for v in &self.custom_views {
567 let path = v.path();
568 if path.is_empty() {
569 tracing::error!(title = v.title(), "admin custom view rejected: empty path");
570 continue;
571 }
572 if !seen.insert(path) {
573 tracing::error!(path, "admin custom view rejected: duplicate path");
574 continue;
575 }
576 out.push(v.clone());
577 }
578 out
579 }
580}
581
582/// Shared state injected into every route via [`axum::extract::State`].
583///
584/// `Arc` makes the clone cheap; the registry is immutable after `build()`.
585#[derive(Clone, Debug)]
586struct AdminState {
587 registry: Arc<AdminRegistry>,
588 /// Flat widget catalog — every widget across all sections.
589 /// Used by `GET /admin/api/dashboard/widgets/<key>/data` to
590 /// look up by key without knowing which section owns it.
591 widget_catalog: Arc<Vec<Widget>>,
592 /// Dashboard sections in render order. Each carries its own
593 /// title + subtitle + widgets. The implicit "Widgets" section
594 /// (from legacy `register_widget(...)` calls) lives at the end.
595 dashboard_sections: Arc<Vec<WidgetSection>>,
596 /// Dashboard model-cards section config. Read by the
597 /// dashboard handler to filter (or skip) the model grid.
598 dashboard_models: DashboardModelsConfig,
599 /// Heading + optional subtitle for the model-cards section.
600 dashboard_models_title: String,
601 dashboard_models_subtitle: Option<String>,
602 /// gaps2 #33 — mirrors `AdminPlugin::restore_last_path`. The index
603 /// handler reads this to decide whether to redirect; the list handler
604 /// reads it to decide whether to write `last_path`.
605 restore_last_path: bool,
606 /// Developer-registered custom views, for the page handler + sidebar.
607 custom_views: Arc<Vec<AdminView>>,
608 /// Gate map: widget key → view permission codename, for every widget
609 /// that belongs to a `.with_permission()`-gated custom view.
610 ///
611 /// Built at `routes()` time from the custom-view registration list and
612 /// checked by `dashboard_widget_data` after `require_staff` — if the
613 /// widget's key is present, the requesting user must also hold the mapped
614 /// codename, or the endpoint returns 403.
615 ///
616 /// Dashboard widgets and widgets in ungated views are NOT in this map,
617 /// so the gate only applies to views that explicitly opt in via
618 /// `.with_permission(...)`.
619 widget_gates: Arc<std::collections::HashMap<String, String>>,
620}
621
622impl AdminState {
623 fn config_for(&self, table: &str) -> Option<&AdminConfig> {
624 self.registry.get(table).map(|r| &r.model)
625 }
626}
627
628/// Gap 107 — join an admin sub-path with the configured base.
629///
630/// `route("/login", "/admin")` → `"/admin/login"`. Used at routes()
631/// construction so every `.route(...)` call honours the
632/// `AdminPlugin::at()` override without hardcoding `/admin` anywhere.
633/// An empty `sub` (the index page) returns the base path itself, so
634/// `route("", "/admin")` → `"/admin"` and not `"/admin/"`.
635fn route(sub: &str, base: &str) -> String {
636 if sub.is_empty() {
637 return base.to_string();
638 }
639 format!("{base}{sub}")
640}
641
642impl Plugin for AdminPlugin {
643 fn name(&self) -> &'static str {
644 "admin"
645 }
646
647 fn dependencies(&self) -> &'static [&'static str] {
648 // Auth is required: login verifies credentials via umbral-auth.
649 // Sessions is required: login creates sessions.
650 &["auth", "sessions"]
651 }
652
653 fn static_files(&self) -> Vec<umbral::plugin::StaticFile> {
654 admin_static_files()
655 }
656
657 fn static_dirs(&self) -> Vec<umbral::plugin::StaticDir> {
658 // The admin ships its assets EMBEDDED (see `static_files()`), so it
659 // works with zero config. This `static_dirs()` entry additionally
660 // exposes the on-disk source so `collect_static` can gather the
661 // admin's `admin.css` / `admin.js` into `<static_root>/admin/` for
662 // CDN / disk serving. Both modes coexist: the embedded specific
663 // route wins in-binary, the collected files serve when a deployment
664 // customises `static_url` or fronts assets with a CDN.
665 //
666 // Because the embedded specific route shadows the pipeline in-binary,
667 // live-editing admin.css won't hot-reload — acceptable for these
668 // framework-internal assets.
669 let source_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
670 .join("src")
671 .join("assets");
672 vec![umbral::plugin::StaticDir::new("admin", source_dir)]
673 }
674
675 fn models(&self) -> Vec<umbral::migrate::ModelMeta> {
676 vec![
677 umbral::migrate::ModelMeta::for_::<crate::models::AdminUserPref>(),
678 umbral::migrate::ModelMeta::for_::<crate::models::AdminAuditLog>(),
679 ]
680 }
681
682 fn routes(&self) -> Router {
683 // Seal the developer-configured branding into the global so
684 // the template engine picks it up on first init. Subsequent
685 // attempts to set it are silent no-ops; the typical flow is
686 // exactly one Plugin::routes() call per process.
687 //
688 // Gap 107: the configured `base_path` rides along with the
689 // branding so templates and handlers read it from one place.
690 // gaps2 #33: `restore_last_path` joins the branding cell so
691 // templates can query the flag (e.g. to emit `?dashboard=1`
692 // on the "Home" breadcrumb link) without a handler pass-through.
693 let mut sealed_branding = self.branding.clone();
694 sealed_branding.base_path = self.base_path.clone();
695 sealed_branding.restore_last_path = self.restore_last_path;
696 let _ = branding::BRANDING.set(sealed_branding);
697
698 // Final section list: developer-declared sections first
699 // (preserving registration order), then an implicit
700 // "Widgets" section at the end containing any legacy
701 // `register_widget(...)` calls. Apps that exclusively
702 // use the new `.dashboard_section(...)` API end up with
703 // a clean sectioned dashboard; apps that only use the
704 // legacy call see one un-sectioned grid like before;
705 // mixed-mode apps see explicit sections first and a
706 // catch-all at the bottom.
707 let mut sections: Vec<WidgetSection> = self.dashboard_sections.clone();
708 if !self.widget_catalog.is_empty() {
709 sections
710 .push(WidgetSection::new("Widgets").widgets(self.widget_catalog.iter().cloned()));
711 }
712 // Flat catalog — feeds the per-widget data API. Built by
713 // flattening every section so a single lookup-by-key
714 // works regardless of which section a widget lives in.
715 let mut catalog: Vec<Widget> = sections
716 .iter()
717 .flat_map(|s| s.widgets.iter().cloned())
718 .collect();
719
720 // Custom-view widgets join the same flat catalog so the per-key
721 // data endpoint resolves them unchanged. Keys are global → warn on dups.
722 // The gate map is built alongside the catalog: for every widget in a
723 // permission-gated view (`.with_permission(codename)`), record
724 // `widget_key → codename` so `dashboard_widget_data` can enforce the
725 // same codename check on the API call, not just on the page load.
726 // gaps3 #7: validate custom-view paths ONCE, up front. Everything
727 // downstream (widget flatten, gate map, sidebar state, route mount)
728 // reads this resolved list so a rejected view is absent everywhere.
729 let resolved_views = self.resolved_custom_views();
730 let mut seen_keys: std::collections::HashSet<&str> =
731 catalog.iter().map(|w| w.key).collect();
732 let mut widget_gates: std::collections::HashMap<String, String> =
733 std::collections::HashMap::new();
734 for v in &resolved_views {
735 for w in v.sections().iter().flat_map(|s| s.widgets.iter()) {
736 if !seen_keys.insert(w.key) {
737 tracing::warn!(
738 widget_key = w.key,
739 view = v.path(),
740 "duplicate widget key across dashboard/custom views; \
741 the data endpoint resolves the first match"
742 );
743 }
744 catalog.push(w.clone());
745 // Only gated views contribute to the gate map.
746 if let Some(perm) = v.permission() {
747 widget_gates.insert(w.key.to_string(), perm.to_string());
748 }
749 }
750 }
751
752 let state = AdminState {
753 registry: Arc::new(self.registry.clone()),
754 widget_catalog: Arc::new(catalog),
755 dashboard_sections: Arc::new(sections),
756 dashboard_models: self.dashboard_models.clone(),
757 dashboard_models_title: self.dashboard_models_title.clone(),
758 dashboard_models_subtitle: self.dashboard_models_subtitle.clone(),
759 restore_last_path: self.restore_last_path,
760 custom_views: Arc::new(resolved_views.clone()),
761 widget_gates: Arc::new(widget_gates),
762 };
763 let mut router = Router::new()
764 // Login / logout (no auth required)
765 .route(
766 &route("/login", &self.base_path),
767 axum::routing::get(login_get).post(login_post),
768 )
769 .route(
770 &route("/logout", &self.base_path),
771 axum::routing::get(logout_handler),
772 )
773 // Index + CRUD routes (all require staff session)
774 .route(
775 &route("", &self.base_path),
776 axum::routing::get(handlers::list::index),
777 )
778 .route(
779 &route("/", &self.base_path),
780 axum::routing::get(handlers::list::index),
781 )
782 .route(
783 &route("/{table}/", &self.base_path),
784 axum::routing::get(handlers::list::list),
785 )
786 .route(
787 &route("/{table}/new", &self.base_path),
788 axum::routing::get(handlers::crud::new_form).post(handlers::crud::create),
789 )
790 .route(
791 &route("/{table}/action", &self.base_path),
792 post(handlers::actions::run_action),
793 )
794 // Phase 2: fragment-only rows endpoint (search/sort/filter/paginate)
795 .route(
796 &route("/{table}/rows", &self.base_path),
797 axum::routing::get(handlers::list::rows_fragment),
798 )
799 // gaps2 #11 round 2: toggle a column's visibility on
800 // the persisted per-table prefs.
801 .route(
802 &route("/{table}/columns/{column}/toggle", &self.base_path),
803 post(handlers::list::toggle_column_visibility),
804 )
805 // Filter dialog fragment
806 .route(
807 &route("/{table}/filter-dialog", &self.base_path),
808 axum::routing::get(handlers::list::filter_dialog_handler),
809 )
810 // Phase 2: new-record sheet (create mode)
811 .route(
812 &route("/{table}/new-sheet", &self.base_path),
813 axum::routing::get(handlers::sheet::new_sheet),
814 )
815 // Phase 2: delete confirm dialog fragment
816 .route(
817 &route("/{table}/{id}/_confirm-delete", &self.base_path),
818 axum::routing::get(handlers::sheet::confirm_delete_dialog),
819 )
820 // Phase 2: sheet fragments (preview + edit)
821 .route(
822 &route("/{table}/{id}/sheet", &self.base_path),
823 axum::routing::get(handlers::sheet::preview_sheet),
824 )
825 .route(
826 &route("/{table}/{id}/edit-sheet", &self.base_path),
827 axum::routing::get(handlers::sheet::edit_sheet_handler),
828 )
829 .route(
830 &route("/{table}/{id}", &self.base_path),
831 axum::routing::get(handlers::crud::detail),
832 )
833 .route(
834 &route("/{table}/{id}/edit", &self.base_path),
835 axum::routing::get(handlers::crud::edit_form).post(handlers::crud::update),
836 )
837 // Phase 2: create via sheet (POST)
838 .route(
839 &route("/{table}/create", &self.base_path),
840 axum::routing::post(handlers::sheet::sheet_create),
841 )
842 // Phase 2: DELETE method for HTMX delete button
843 .route(
844 &route("/{table}/{id}", &self.base_path),
845 axum::routing::delete(handlers::crud::htmx_delete),
846 )
847 .route(
848 &route("/{table}/{id}/delete", &self.base_path),
849 post(handlers::crud::delete),
850 )
851 // Phase 3: per-key action dispatch
852 .route(
853 &route("/{table}/actions/{key}", &self.base_path),
854 axum::routing::post(handlers::actions::dispatch_action),
855 )
856 // Phase 3: FK/M2M async picker endpoints
857 .route(
858 &route("/api/{table}/{field}/options/resolve", &self.base_path),
859 axum::routing::get(handlers::fk_picker::fk_options_resolve),
860 )
861 .route(
862 &route("/api/{table}/{field}/options", &self.base_path),
863 axum::routing::get(handlers::fk_picker::fk_options),
864 )
865 // Phase 3: inline cell edit
866 .route(
867 &route("/{table}/{id}/cell/{field}/edit", &self.base_path),
868 axum::routing::get(handlers::inline_edit::cell_edit_get),
869 )
870 .route(
871 &route("/{table}/{id}/cell/{field}", &self.base_path),
872 axum::routing::post(handlers::inline_edit::cell_edit_post),
873 )
874 // Password change for models with password_field set
875 .route(
876 &route("/{table}/{id}/change-password", &self.base_path),
877 axum::routing::post(handlers::sheet::change_password_handler),
878 )
879 // Phase 4: user prefs
880 .route(
881 &route("/api/prefs", &self.base_path),
882 axum::routing::get(handlers::prefs::get_prefs_handler)
883 .put(handlers::prefs::put_prefs_handler),
884 )
885 // Phase 4: audit history
886 .route(
887 &route("/{table}/{id}/history", &self.base_path),
888 axum::routing::get(handlers::history::history_handler),
889 )
890 // Phase 4: dashboard
891 .route(
892 &route("/api/dashboard/catalog", &self.base_path),
893 axum::routing::get(handlers::dashboard::dashboard_catalog),
894 )
895 .route(
896 &route("/api/dashboard/layout", &self.base_path),
897 axum::routing::get(handlers::dashboard::dashboard_layout_get)
898 .put(handlers::dashboard::dashboard_layout_put),
899 )
900 .route(
901 &route("/api/dashboard/widgets/{key}/data", &self.base_path),
902 axum::routing::get(handlers::dashboard::dashboard_widget_data),
903 )
904 // CSV export of a widget's own payload, computed from the SAME
905 // resolved filters the dashboard is showing — so the file matches
906 // the chart you exported it from. Shares `gate_widget` with the data
907 // endpoint, so it cannot become a way to read numbers you are not
908 // allowed to see.
909 .route(
910 &route("/api/dashboard/widgets/{key}/export.csv", &self.base_path),
911 axum::routing::get(handlers::dashboard::dashboard_widget_export),
912 )
913 // gaps2 #36: EasyMDE markdown-editor image upload. Staff-gated
914 // (no `{table}` — a media upload isn't scoped to one model), and
915 // stores through the ambient `umbral::storage` seam. Returns
916 // `{ "url": ... }` for the editor's `imageUploadFunction`.
917 .route(
918 &route("/upload-image", &self.base_path),
919 post(handlers::upload::upload_image),
920 )
921 // Phase 4: command palette fragment + global record search
922 .route(
923 &route("/api/palette", &self.base_path),
924 axum::routing::get(handlers::palette::palette_fragment),
925 )
926 .route(
927 &route("/api/palette/search", &self.base_path),
928 axum::routing::get(handlers::palette::palette_search),
929 )
930 // Static admin.css is mounted by the framework via
931 // `static_files()` — no manual route needed here.
932 ;
933 // Mount one GET route per registered custom view at
934 // `{base}/{view.path}`. The per-invocation `slug` clone keeps the
935 // handler `Clone` (axum requires the handler future factory to be
936 // cloneable across concurrent requests).
937 for v in &resolved_views {
938 let slug = v.path().to_string();
939 let full = route(&format!("/custom-views/{}/", v.path()), &self.base_path);
940 router = router.route(
941 &full,
942 axum::routing::get({
943 let slug = slug.clone();
944 move |state: axum::extract::State<AdminState>,
945 headers: axum::http::HeaderMap| {
946 let slug = slug.clone();
947 async move {
948 crate::handlers::custom_view::custom_view(state, headers, slug).await
949 }
950 }
951 }),
952 );
953 }
954 router.with_state(state)
955 }
956
957 fn route_paths(&self) -> Vec<umbral::routes::RouteSpec> {
958 // Companion list to `routes()` — surfaced by the dev-mode
959 // default 404 page. Each entry pairs a path pattern with the
960 // HTTP methods it accepts; keep in sync with the `.route(...)`
961 // calls above. Mismatch is "stale route list," not a routing
962 // bug.
963 use umbral::routes::RouteSpec;
964 // Method shorthands — each constructed once and `clone()`d per
965 // entry so the source list stays one-line-per-route.
966 let g = || vec!["GET"];
967 let p = || vec!["POST"];
968 let gp = || vec!["GET", "POST"];
969 let gpd = || vec!["GET", "POST", "DELETE"];
970 let gput = || vec!["GET", "PUT"];
971 let mut specs = vec![
972 RouteSpec::new(&route("", &self.base_path), g()),
973 RouteSpec::new(&route("/", &self.base_path), g()),
974 RouteSpec::new(&route("/login", &self.base_path), gp()),
975 RouteSpec::new(&route("/logout", &self.base_path), g()),
976 RouteSpec::new(&route("/{table}/", &self.base_path), g()),
977 RouteSpec::new(&route("/{table}/new", &self.base_path), gp()),
978 RouteSpec::new(&route("/{table}/action", &self.base_path), p()),
979 RouteSpec::new(&route("/{table}/rows", &self.base_path), g()),
980 RouteSpec::new(&route("/{table}/filter-dialog", &self.base_path), g()),
981 RouteSpec::new(&route("/{table}/new-sheet", &self.base_path), g()),
982 RouteSpec::new(&route("/{table}/create", &self.base_path), p()),
983 RouteSpec::new(&route("/{table}/{id}", &self.base_path), gpd()),
984 RouteSpec::new(&route("/{table}/{id}/edit", &self.base_path), gp()),
985 RouteSpec::new(&route("/{table}/{id}/edit-sheet", &self.base_path), g()),
986 RouteSpec::new(&route("/{table}/{id}/sheet", &self.base_path), g()),
987 RouteSpec::new(&route("/{table}/{id}/delete", &self.base_path), p()),
988 RouteSpec::new(
989 &route("/{table}/{id}/_confirm-delete", &self.base_path),
990 g(),
991 ),
992 RouteSpec::new(&route("/{table}/{id}/history", &self.base_path), g()),
993 RouteSpec::new(
994 &route("/{table}/{id}/change-password", &self.base_path),
995 p(),
996 ),
997 RouteSpec::new(&route("/{table}/{id}/cell/{field}", &self.base_path), p()),
998 RouteSpec::new(
999 &route("/{table}/{id}/cell/{field}/edit", &self.base_path),
1000 g(),
1001 ),
1002 RouteSpec::new(&route("/{table}/actions/{key}", &self.base_path), p()),
1003 RouteSpec::new(&route("/api/{table}/{field}/options", &self.base_path), g()),
1004 RouteSpec::new(
1005 &route("/api/{table}/{field}/options/resolve", &self.base_path),
1006 g(),
1007 ),
1008 RouteSpec::new(&route("/api/prefs", &self.base_path), gput()),
1009 RouteSpec::new(&route("/upload-image", &self.base_path), p()),
1010 RouteSpec::new(&route("/api/palette", &self.base_path), g()),
1011 RouteSpec::new(&route("/api/palette/search", &self.base_path), g()),
1012 RouteSpec::new(&route("/api/dashboard/catalog", &self.base_path), g()),
1013 RouteSpec::new(&route("/api/dashboard/layout", &self.base_path), gput()),
1014 RouteSpec::new(
1015 &route("/api/dashboard/widgets/{key}/data", &self.base_path),
1016 g(),
1017 ),
1018 ];
1019 // Companion entries for the developer-registered custom views,
1020 // mounted in `routes()` as `GET {base}/{view.path}`.
1021 for v in &self.resolved_custom_views() {
1022 specs.push(RouteSpec::new(
1023 &format!("{}/custom-views/{}/", self.base_path, v.path()),
1024 g(),
1025 ));
1026 }
1027 specs
1028 }
1029
1030 fn on_ready(
1031 &self,
1032 _ctx: &umbral::plugin::AppContext,
1033 ) -> Result<(), umbral::plugin::PluginError> {
1034 // Tables are produced by the migration engine off
1035 // `Self::models()` — same path as every other plugin's models.
1036 // No bootstrap DDL here.
1037
1038 // CSRF posture (audit_2 admin #5). The admin's mutating handlers
1039 // (create / update / delete / bulk-action / inline-edit / upload /
1040 // prefs) do NOT self-verify a CSRF token — only `login_post` does — so
1041 // cross-site request forgery is defended by the session cookie's
1042 // `SameSite` attribute. `SameSite=Lax` (the default) already blocks the
1043 // forged cross-site POST/PUT/DELETE that would carry the session
1044 // cookie. If an operator sets `SameSite=None` (e.g. to serve a
1045 // cross-origin SPA) that defense is gone, so the admin's mutations are
1046 // CSRF-forgeable unless a CSRF middleware is mounted. `on_ready` runs in
1047 // topological order and the admin depends on `sessions`, so the sealed
1048 // value is readable here. Warn loudly rather than fail (a cross-origin
1049 // API with a properly-mounted CSRF layer is a legitimate setup).
1050 if umbral_sessions::configured_same_site() == umbral_sessions::SameSite::None {
1051 tracing::warn!(
1052 "umbral-admin: the session cookie is SameSite=None, which removes the \
1053 cross-site-request CSRF defense the admin's mutating handlers rely on. \
1054 Mount a CSRF middleware (umbral-security's SecurityPlugin) so admin \
1055 create/update/delete/upload/prefs actions can't be forged cross-site, \
1056 or keep the session cookie at SameSite=Lax/Strict for same-origin admin use."
1057 );
1058 }
1059 Ok(())
1060 }
1061}
1062
1063// =========================================================================
1064// Sidebar context helpers.
1065//
1066// Every handler that renders the authenticated shell calls `sidebar_apps`
1067// to pass the nav tree into the template.
1068// =========================================================================
1069
1070// =========================================================================
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075
1076 #[test]
1077 fn admin_model_defaults() {
1078 let m = AdminModel::new("post");
1079 assert_eq!(m.get_list_per_page(), 25);
1080 assert!(m.inlines.is_empty());
1081 assert!(m.label.is_none());
1082 assert!(m.icon.is_none());
1083 }
1084
1085 #[test]
1086 fn admin_config_alias_compiles() {
1087 // The type alias must be identical to AdminModel at the Rust level.
1088 let _: AdminConfig = AdminModel::new("test");
1089 }
1090
1091 #[test]
1092 fn static_files_use_unified_static_url() {
1093 // The embedded admin assets now mount on the unified `/static/admin/…`
1094 // pipeline URL (default `static_url`), not the legacy `/admin/static/…`.
1095 let files = AdminPlugin::default().static_files();
1096 let paths: Vec<&str> = files.iter().map(|f| f.url_path).collect();
1097 assert!(
1098 paths.contains(&"/static/admin/admin.css"),
1099 "admin.css should mount at /static/admin/admin.css, got {paths:?}"
1100 );
1101 assert!(
1102 paths.contains(&"/static/admin/admin.js"),
1103 "admin.js should mount at /static/admin/admin.js, got {paths:?}"
1104 );
1105 // Both still ship non-trivial embedded bytes (zero-config preserved).
1106 for f in &files {
1107 assert!(
1108 f.body.len() > 100,
1109 "{} should ship embedded bytes, got {} bytes",
1110 f.url_path,
1111 f.body.len()
1112 );
1113 }
1114 }
1115
1116 #[test]
1117 fn static_dirs_maps_admin_namespace_to_existing_assets_dir() {
1118 let dirs = AdminPlugin::default().static_dirs();
1119 assert_eq!(dirs.len(), 1, "admin contributes exactly one static dir");
1120 let dir = &dirs[0];
1121 assert_eq!(dir.namespace, "admin");
1122 // The source dir actually exists on disk and holds the css/js the
1123 // embedded route serves — so `collect_static` has real files to gather.
1124 assert!(
1125 dir.source_dir.join("admin.css").is_file(),
1126 "{} should contain admin.css",
1127 dir.source_dir.display()
1128 );
1129 assert!(
1130 dir.source_dir.join("admin.js").is_file(),
1131 "{} should contain admin.js",
1132 dir.source_dir.display()
1133 );
1134 }
1135}
1136
1137#[cfg(test)]
1138mod custom_view_wiring_tests {
1139 use super::*;
1140 use crate::views::AdminView;
1141 use crate::widgets::{
1142 KpiPayload, Widget, WidgetDataFn, WidgetKind, WidgetPayload, WidgetSection,
1143 };
1144
1145 fn tiny_kpi(key: &'static str) -> Widget {
1146 Widget {
1147 key,
1148 title: "T".into(),
1149 kind: WidgetKind::Kpi,
1150 default_span: Default::default(),
1151 permission: None,
1152 data: WidgetDataFn::new(|_user| async {
1153 WidgetPayload::Kpi(KpiPayload {
1154 value: "0".into(),
1155 unit: None,
1156 delta: None,
1157 sparkline: None,
1158 })
1159 }),
1160 default_period: None,
1161 filters: Vec::new(),
1162 }
1163 }
1164
1165 // gaps3 #7 — a DUPLICATE view path is dropped (logged), not panicked.
1166 // Views mount under the /custom-views/ namespace, so a path that looks
1167 // like a built-in route ("login") or a table can't collide — only an
1168 // exact duplicate path would make axum's router panic at boot.
1169 #[test]
1170 fn resolved_custom_views_drops_duplicate_paths() {
1171 let plugin = AdminPlugin::default()
1172 .view(AdminView::new("reports/sales", "A"))
1173 .view(AdminView::new("reports/sales", "dup B")) // duplicate → dropped
1174 .view(AdminView::new("login", "safe under the namespace")) // /custom-views/login/ — no collision
1175 .view(AdminView::new("reports/ok", "C")); // valid, distinct
1176
1177 let resolved = plugin.resolved_custom_views();
1178 let paths: Vec<&str> = resolved.iter().map(|v| v.path()).collect();
1179 assert_eq!(
1180 paths,
1181 vec!["reports/sales", "login", "reports/ok"],
1182 "only the exact duplicate is dropped (first wins); a 'login' path is fine under /custom-views/"
1183 );
1184
1185 // The real regression: routes() must not panic on the duplicate
1186 // registration now that the resolver drops it first.
1187 let _router = plugin.routes();
1188 }
1189
1190 #[test]
1191 fn view_registers_and_flattens_widgets_into_catalog() {
1192 let plugin = AdminPlugin::default().view(
1193 AdminView::new("reports/sales", "Sales")
1194 .section(WidgetSection::new("S").widget(tiny_kpi("rpt_sales_total"))),
1195 );
1196 // The view is stored on the plugin.
1197 assert_eq!(plugin.custom_views.len(), 1);
1198 assert_eq!(plugin.custom_views[0].path(), "reports/sales");
1199
1200 // The same flatten the `routes()` builder performs: a registered
1201 // view's widgets become reachable in the global key catalog so the
1202 // per-key data endpoint resolves them unchanged.
1203 let catalog_keys: Vec<&str> = plugin
1204 .custom_views
1205 .iter()
1206 .flat_map(|v| v.sections().iter())
1207 .flat_map(|s| s.widgets.iter())
1208 .map(|w| w.key)
1209 .collect();
1210 assert!(
1211 catalog_keys.contains(&"rpt_sales_total"),
1212 "the view's widget key should be flattenable into the catalog, got {catalog_keys:?}"
1213 );
1214 }
1215}