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;
55pub mod widgets;
56
57mod auth;
58mod branding;
59mod discovery;
60mod engine;
61mod inlines;
62mod error;
63mod handlers;
64mod pagination;
65mod permcheck;
66mod rows;
67mod static_assets;
68mod util;
69mod view;
70
71pub mod files;
72
73pub(crate) use auth::{login_get, login_post, logout_handler};
74pub(crate) use error::AdminError;
75pub use files::{file_descriptor, resolve_preview_kind};
76pub(crate) use static_assets::admin_static_files;
77pub(crate) use util::q;
78
79pub use config::{
80 Action, ActionInvocation, ActionResult, ActionScope, ActionVariant, AdminConfig, AdminContext,
81 AdminModel, InlineKind, InlineModel, ToastLevel,
82};
83pub use registry::{AdminRegistration, AdminRegistry, App as AdminApp};
84// The two builtin dashboard widgets — `Models by Plugin` (bar)
85// and `Recent Signups` (feed). Used to be auto-prepended to the
86// catalog; now exposed as public functions so the caller can
87// register them at the position they want and resize via
88// `.with_span(cols, rows)`. See `AdminPlugin::register_widget`
89// for the wiring shape.
90pub use handlers::dashboard::{builtin_recent_users_widget, builtin_total_models_widget};
91pub use widgets::{
92 BarPayload, CardPayload, CatalogEntry, ChartPoint, DonutPayload, DonutSlice, FeedItem,
93 FeedPayload, HeatmapCell, HeatmapPayload, HeatmapRow, KpiPayload, LinePayload, ProgressItem,
94 ProgressPayload, RadialPayload, RadialTrack, Series, Span, TableColumn, TablePayload, Widget,
95 WidgetDataFn, WidgetInstance, WidgetKind, WidgetParams, WidgetPayload, WidgetSection,
96 format_thousands, humanize_number,
97};
98
99use std::sync::Arc;
100
101use umbral::prelude::*;
102use umbral::web::post;
103
104// =========================================================================
105// Plugin struct
106// =========================================================================
107
108/// The plugin. Mounts every admin route under `/admin`.
109///
110/// Use [`AdminPlugin::register`] to attach an [`AdminModel`] before
111/// passing the plugin to `App::builder().plugin(...)`.
112///
113/// ```ignore
114/// use umbral_admin::{AdminPlugin, AdminModel, Action};
115///
116/// let admin = AdminPlugin::default()
117/// .register(
118/// AdminModel::new("post")
119/// .list_display(&["title", "author", "published_at"])
120/// .list_filter(&["published"])
121/// .search_fields(&["title", "body"])
122/// .ordering(&["-published_at"])
123/// .readonly_fields(&["created_at"])
124/// .actions(vec![Action::delete_selected()]),
125/// );
126///
127/// App::builder()
128/// .plugin(AuthPlugin::default())
129/// .plugin(admin)
130/// .build()?;
131/// ```
132/// How the dashboard renders its "Models" cards section.
133///
134/// Default: [`Self::All`] — every registered model gets a card.
135/// This works for a 5-20 model app but turns into a wall of 200
136/// cards on a real-world enterprise install. Use [`Self::Only`]
137/// to pick a curated subset, or [`Self::Hidden`] to drop the
138/// section entirely (e.g. when the operator's primary view is
139/// purely widget-driven).
140#[derive(Debug, Clone)]
141pub enum DashboardModelsConfig {
142 /// Default — show a card for every registered model.
143 All,
144 /// Hide the section entirely. The dashboard becomes:
145 /// greeting → quick stats → widgets, no model grid.
146 Hidden,
147 /// Show only these tables, in the given order. Unknown
148 /// table names are dropped silently (typo-safe; if a
149 /// plugin you reference is unregistered the rest still
150 /// render).
151 Only(Vec<String>),
152}
153
154impl Default for DashboardModelsConfig {
155 fn default() -> Self {
156 Self::All
157 }
158}
159
160#[derive(Debug, Clone)]
161pub struct AdminPlugin {
162 registry: AdminRegistry,
163 widget_catalog: Vec<Widget>,
164 /// Explicit named sections (each with title + subtitle + widget
165 /// list). Empty by default — back-compat for apps that only use
166 /// the legacy `register_widget` call. When non-empty, the
167 /// dashboard renders these sections first; any widgets in
168 /// `widget_catalog` get an implicit final "Widgets" section.
169 dashboard_sections: Vec<WidgetSection>,
170 branding: branding::AdminBranding,
171 /// Gap 107: base URL prefix for every admin route. Default
172 /// `/admin`. Override with `AdminPlugin::default().at("/myadmin")`.
173 /// Always normalised to one leading slash, no trailing slash.
174 base_path: String,
175 /// Dashboard model-cards config. Defaults to `All` so the
176 /// dashboard does something sensible on a fresh install.
177 dashboard_models: DashboardModelsConfig,
178 /// Heading shown above the model-cards section. Default
179 /// "Models" — override with `.dashboard_models_title(...)`.
180 dashboard_models_title: String,
181 /// Optional one-line subtitle under the heading.
182 dashboard_models_subtitle: Option<String>,
183 /// gaps2 #33 — "restore where I left off" feature flag. Default
184 /// `true` (on by default; opt out to disable).
185 /// When `true`: `/admin/` 302-redirects to `last_path` if one is
186 /// stored; the changelist handler writes `last_path` on every visit;
187 /// the "Home" breadcrumb carries `?dashboard=1` as an escape hatch.
188 /// When `false`: `/admin/` always renders the dashboard; the
189 /// changelist handler skips the `last_path` write (no dead data).
190 restore_last_path: bool,
191}
192
193impl Default for AdminPlugin {
194 fn default() -> Self {
195 Self {
196 registry: AdminRegistry::default(),
197 widget_catalog: Vec::new(),
198 dashboard_sections: Vec::new(),
199 branding: branding::AdminBranding::default(),
200 base_path: "/admin".to_string(),
201 dashboard_models: DashboardModelsConfig::default(),
202 dashboard_models_title: "Models".to_string(),
203 dashboard_models_subtitle: None,
204 restore_last_path: true,
205 }
206 }
207}
208
209impl AdminPlugin {
210 /// Register an [`AdminModel`] for one model. Chainable.
211 ///
212 /// If two configs are registered for the same table the last one wins
213 /// (a duplicate registration overwrites the earlier one).
214 ///
215 /// The plugin name defaults to `"admin"` for models registered before
216 /// the plugin is installed into the app. From M7+ plugins will pass
217 /// their own name via `Plugin::admin_register` on the registry.
218 pub fn register(mut self, model: AdminModel) -> Self {
219 self.registry.register("admin", model);
220 self
221 }
222
223 /// Register many [`AdminModel`]s at once — the batch form of
224 /// [`register`](Self::register). Lets each plugin export a
225 /// `Vec<AdminModel>` (its admin surface, declared next to its models)
226 /// and the app register them in one call instead of a `.register(...)`
227 /// per model in `main.rs`.
228 ///
229 /// ```ignore
230 /// // plugins/blog/src/lib.rs
231 /// pub fn admin_models() -> Vec<umbral_admin::AdminModel> {
232 /// vec![post_admin(), comment_admin(), tag_admin()]
233 /// }
234 ///
235 /// // main.rs
236 /// AdminPlugin::default().register_many(blog::admin_models())
237 /// ```
238 pub fn register_many(mut self, models: impl IntoIterator<Item = AdminModel>) -> Self {
239 for model in models {
240 self = self.register(model);
241 }
242 self
243 }
244
245 /// Register an [`AdminModel`] for a specific plugin name.
246 ///
247 /// This is the method the `Plugin::routes` / `on_ready` pathway uses
248 /// when a plugin contributes its own admin registrations. The sidebar
249 /// groups models by the `plugin_name` supplied here.
250 pub fn register_for(mut self, plugin_name: &str, model: AdminModel) -> Self {
251 self.registry.register(plugin_name, model);
252 self
253 }
254
255 /// Batch form of [`register_for`](Self::register_for) — register many
256 /// models under one plugin name (the `Plugin`-pathway batch entry).
257 pub fn register_for_many(
258 mut self,
259 plugin_name: &str,
260 models: impl IntoIterator<Item = AdminModel>,
261 ) -> Self {
262 for model in models {
263 self = self.register_for(plugin_name, model);
264 }
265 self
266 }
267
268 /// Register a dashboard widget. Chainable.
269 ///
270 /// # Example
271 ///
272 /// ```rust,ignore
273 /// use umbral_admin::{AdminPlugin, Widget, WidgetKind, WidgetDataFn, WidgetPayload, KpiPayload, Span};
274 ///
275 /// AdminPlugin::default()
276 /// .register_widget(Widget {
277 /// key: "total_posts",
278 /// title: "Total Posts".to_string(),
279 /// kind: WidgetKind::Kpi,
280 /// default_span: Span { cols: 3, rows: 1 },
281 /// permission: None,
282 /// data: WidgetDataFn::new(|_user| async move {
283 /// WidgetPayload::Kpi(KpiPayload {
284 /// value: "0".to_string(),
285 /// unit: None, delta: None, sparkline: None,
286 /// })
287 /// }),
288 /// });
289 /// ```
290 pub fn register_widget(mut self, widget: Widget) -> Self {
291 self.widget_catalog.push(widget);
292 self
293 }
294
295 /// Override the admin site title — shown in the browser tab,
296 /// the sidebar header, and the login page.
297 ///
298 /// ```ignore
299 /// AdminPlugin::default().site_title("Acme Backoffice")
300 /// ```
301 pub fn site_title(mut self, title: impl Into<String>) -> Self {
302 self.branding.site_title = title.into();
303 self
304 }
305
306 /// One-line description shown on the dashboard / login page
307 /// underneath the site title.
308 pub fn site_description(mut self, description: impl Into<String>) -> Self {
309 self.branding.site_description = description.into();
310 self
311 }
312
313 /// Override the brand primary color. Accepts any valid CSS color
314 /// (`#5b5bd6`, `rgb(91 91 214)`, `hsl(240 60% 60%)`). The wrapper
315 /// template emits a `<style>` that re-assigns `--primary` and
316 /// `--primary-container` so every "primary"-tinted element across
317 /// the admin picks it up automatically.
318 pub fn brand_color(mut self, color: impl Into<String>) -> Self {
319 self.branding.brand_color = color.into();
320 self
321 }
322
323 /// Gap 107: mount the admin at a path other than the default
324 /// `/admin`. Useful when a single domain hosts multiple umbral
325 /// admins, or when the operations team enforces a different
326 /// vanity URL. Accepts `"/myadmin"`, `"myadmin"`, or
327 /// `"/myadmin/"` — all normalise to `"/myadmin"`.
328 ///
329 /// ```ignore
330 /// AdminPlugin::default().at("/backoffice")
331 /// // → routes mount at /backoffice/login, /backoffice/{table}/, ...
332 /// ```
333 ///
334 /// Templates read the configured base via the `admin_base`
335 /// Jinja global, so cross-page links resolve to the new path
336 /// automatically. Handler-side redirects and `sanitise_next`
337 /// also use the configured base.
338 pub fn at(mut self, path: impl Into<String>) -> Self {
339 let raw = path.into();
340 let trimmed = raw.trim_matches('/');
341 self.base_path = if trimmed.is_empty() {
342 String::new()
343 } else {
344 format!("/{trimmed}")
345 };
346 self
347 }
348
349 /// The normalised admin base path. Public so plugin authors and
350 /// the OpenAPI plugin can reference it.
351 pub fn base_path(&self) -> &str {
352 &self.base_path
353 }
354
355 /// Hide the dashboard's "Models" cards section entirely. Use
356 /// when the operator's primary view is widget-driven and a
357 /// long model grid would be noise (200-model enterprise
358 /// installs, single-purpose admins, etc.).
359 ///
360 /// ```ignore
361 /// AdminPlugin::default().dashboard_models_hidden()
362 /// ```
363 pub fn dashboard_models_hidden(mut self) -> Self {
364 self.dashboard_models = DashboardModelsConfig::Hidden;
365 self
366 }
367
368 /// Show only a curated subset of models on the dashboard, in
369 /// the given order. Unknown table names are dropped silently
370 /// (typo-safe — if one plugin is unregistered the rest still
371 /// render).
372 ///
373 /// ```ignore
374 /// AdminPlugin::default().dashboard_models_only(&[
375 /// "product", "order", "customer",
376 /// ])
377 /// ```
378 ///
379 /// Type-safe alternative coming in a follow-up: a
380 /// `models![Product, Order, Customer]` macro that resolves
381 /// each type to its `Model::TABLE` so a rename in the
382 /// struct doesn't require updating string references here.
383 pub fn dashboard_models_only<S: Into<String> + Clone>(mut self, tables: &[S]) -> Self {
384 self.dashboard_models =
385 DashboardModelsConfig::Only(tables.iter().cloned().map(Into::into).collect());
386 self
387 }
388
389 /// Explicit reset to the default — show every registered
390 /// model. Useful when a wrapper builder has previously
391 /// configured a subset / hidden and you want the full grid
392 /// back.
393 pub fn dashboard_models_all(mut self) -> Self {
394 self.dashboard_models = DashboardModelsConfig::All;
395 self
396 }
397
398 /// Append a named widget section to the dashboard. Sections
399 /// render in registration order, each with its own heading
400 /// + (optional) subtitle + widget grid:
401 ///
402 /// ```ignore
403 /// AdminPlugin::default()
404 /// .dashboard_section(
405 /// WidgetSection::new("Sales overview")
406 /// .subtitle("Daily KPIs across the storefront")
407 /// .widget(shop_total_sales_widget())
408 /// .widget(shop_orders_widget()))
409 /// .dashboard_section(
410 /// WidgetSection::new("Engagement")
411 /// .widget(umbral_admin::builtin_recent_users_widget()))
412 /// ```
413 ///
414 /// Widgets registered via the legacy `register_widget(...)`
415 /// land in an implicit final section titled "Widgets" so
416 /// pre-existing apps keep working without refactor.
417 pub fn dashboard_section(mut self, section: WidgetSection) -> Self {
418 self.dashboard_sections.push(section);
419 self
420 }
421
422 /// Insert a section at a specific position in the dashboard.
423 /// Useful when a wrapper builder appended sections earlier
424 /// and you want a new one above them. `index` is clamped at
425 /// the current section count, so `usize::MAX` is equivalent
426 /// to [`Self::dashboard_section`].
427 ///
428 /// ```ignore
429 /// AdminPlugin::default()
430 /// .dashboard_section(sales_section)
431 /// .dashboard_section(system_section)
432 /// // Slot a new section between the two:
433 /// .dashboard_section_at(1, alerts_section)
434 /// ```
435 pub fn dashboard_section_at(mut self, index: usize, section: WidgetSection) -> Self {
436 let i = index.min(self.dashboard_sections.len());
437 self.dashboard_sections.insert(i, section);
438 self
439 }
440
441 /// Override the heading shown above the model-cards section.
442 /// Default "Models". Pair with `dashboard_models_subtitle`
443 /// for a one-line explainer.
444 pub fn dashboard_models_title(mut self, title: impl Into<String>) -> Self {
445 self.dashboard_models_title = title.into();
446 self
447 }
448
449 /// Optional one-line caption under the model-cards heading.
450 pub fn dashboard_models_subtitle(mut self, subtitle: impl Into<String>) -> Self {
451 self.dashboard_models_subtitle = Some(subtitle.into());
452 self
453 }
454
455 /// Control whether the admin "restore where I left off" feature is
456 /// active (default: **`true`** — on by default, opt out to disable).
457 ///
458 /// When enabled (`true`, the default):
459 /// - `/admin/` 302-redirects to the last-visited changelist URL
460 /// stored in `admin_user_pref.preferences.last_path`.
461 /// - The changelist handler writes `last_path` on every page visit.
462 /// - The "Home" breadcrumb carries `?dashboard=1` so the dashboard
463 /// is reachable in one click (the escape hatch becomes a UI affordance).
464 ///
465 /// When disabled (`false`):
466 /// - `/admin/` always renders the dashboard directly.
467 /// - The changelist handler skips the `last_path` write — no dead
468 /// data accumulates in `admin_user_pref.preferences`.
469 ///
470 /// ```ignore
471 /// AdminPlugin::default().restore_last_path(false)
472 /// ```
473 pub fn restore_last_path(mut self, enabled: bool) -> Self {
474 self.restore_last_path = enabled;
475 self
476 }
477}
478
479/// Shared state injected into every route via [`axum::extract::State`].
480///
481/// `Arc` makes the clone cheap; the registry is immutable after `build()`.
482#[derive(Clone, Debug)]
483struct AdminState {
484 registry: Arc<AdminRegistry>,
485 /// Flat widget catalog — every widget across all sections.
486 /// Used by `GET /admin/api/dashboard/widgets/<key>/data` to
487 /// look up by key without knowing which section owns it.
488 widget_catalog: Arc<Vec<Widget>>,
489 /// Dashboard sections in render order. Each carries its own
490 /// title + subtitle + widgets. The implicit "Widgets" section
491 /// (from legacy `register_widget(...)` calls) lives at the end.
492 dashboard_sections: Arc<Vec<WidgetSection>>,
493 /// Dashboard model-cards section config. Read by the
494 /// dashboard handler to filter (or skip) the model grid.
495 dashboard_models: DashboardModelsConfig,
496 /// Heading + optional subtitle for the model-cards section.
497 dashboard_models_title: String,
498 dashboard_models_subtitle: Option<String>,
499 /// gaps2 #33 — mirrors `AdminPlugin::restore_last_path`. The index
500 /// handler reads this to decide whether to redirect; the list handler
501 /// reads it to decide whether to write `last_path`.
502 restore_last_path: bool,
503}
504
505impl AdminState {
506 fn config_for(&self, table: &str) -> Option<&AdminConfig> {
507 self.registry.get(table).map(|r| &r.model)
508 }
509}
510
511/// Gap 107 — join an admin sub-path with the configured base.
512///
513/// `route("/login", "/admin")` → `"/admin/login"`. Used at routes()
514/// construction so every `.route(...)` call honours the
515/// `AdminPlugin::at()` override without hardcoding `/admin` anywhere.
516/// An empty `sub` (the index page) returns the base path itself, so
517/// `route("", "/admin")` → `"/admin"` and not `"/admin/"`.
518fn route(sub: &str, base: &str) -> String {
519 if sub.is_empty() {
520 return base.to_string();
521 }
522 format!("{base}{sub}")
523}
524
525impl Plugin for AdminPlugin {
526 fn name(&self) -> &'static str {
527 "admin"
528 }
529
530 fn dependencies(&self) -> &'static [&'static str] {
531 // Auth is required: login verifies credentials via umbral-auth.
532 // Sessions is required: login creates sessions.
533 &["auth", "sessions"]
534 }
535
536 fn static_files(&self) -> Vec<umbral::plugin::StaticFile> {
537 admin_static_files()
538 }
539
540 fn static_dirs(&self) -> Vec<umbral::plugin::StaticDir> {
541 // The admin ships its assets EMBEDDED (see `static_files()`), so it
542 // works with zero config. This `static_dirs()` entry additionally
543 // exposes the on-disk source so `collect_static` can gather the
544 // admin's `admin.css` / `admin.js` into `<static_root>/admin/` for
545 // CDN / disk serving. Both modes coexist: the embedded specific
546 // route wins in-binary, the collected files serve when a deployment
547 // customises `static_url` or fronts assets with a CDN.
548 //
549 // Because the embedded specific route shadows the pipeline in-binary,
550 // live-editing admin.css won't hot-reload — acceptable for these
551 // framework-internal assets.
552 let source_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
553 .join("src")
554 .join("assets");
555 vec![umbral::plugin::StaticDir::new("admin", source_dir)]
556 }
557
558 fn models(&self) -> Vec<umbral::migrate::ModelMeta> {
559 vec![
560 umbral::migrate::ModelMeta::for_::<crate::models::AdminUserPref>(),
561 umbral::migrate::ModelMeta::for_::<crate::models::AdminAuditLog>(),
562 ]
563 }
564
565 fn routes(&self) -> Router {
566 // Seal the developer-configured branding into the global so
567 // the template engine picks it up on first init. Subsequent
568 // attempts to set it are silent no-ops; the typical flow is
569 // exactly one Plugin::routes() call per process.
570 //
571 // Gap 107: the configured `base_path` rides along with the
572 // branding so templates and handlers read it from one place.
573 // gaps2 #33: `restore_last_path` joins the branding cell so
574 // templates can query the flag (e.g. to emit `?dashboard=1`
575 // on the "Home" breadcrumb link) without a handler pass-through.
576 let mut sealed_branding = self.branding.clone();
577 sealed_branding.base_path = self.base_path.clone();
578 sealed_branding.restore_last_path = self.restore_last_path;
579 let _ = branding::BRANDING.set(sealed_branding);
580
581 // Final section list: developer-declared sections first
582 // (preserving registration order), then an implicit
583 // "Widgets" section at the end containing any legacy
584 // `register_widget(...)` calls. Apps that exclusively
585 // use the new `.dashboard_section(...)` API end up with
586 // a clean sectioned dashboard; apps that only use the
587 // legacy call see one un-sectioned grid like before;
588 // mixed-mode apps see explicit sections first and a
589 // catch-all at the bottom.
590 let mut sections: Vec<WidgetSection> = self.dashboard_sections.clone();
591 if !self.widget_catalog.is_empty() {
592 sections
593 .push(WidgetSection::new("Widgets").widgets(self.widget_catalog.iter().cloned()));
594 }
595 // Flat catalog — feeds the per-widget data API. Built by
596 // flattening every section so a single lookup-by-key
597 // works regardless of which section a widget lives in.
598 let catalog: Vec<Widget> = sections
599 .iter()
600 .flat_map(|s| s.widgets.iter().cloned())
601 .collect();
602
603 let state = AdminState {
604 registry: Arc::new(self.registry.clone()),
605 widget_catalog: Arc::new(catalog),
606 dashboard_sections: Arc::new(sections),
607 dashboard_models: self.dashboard_models.clone(),
608 dashboard_models_title: self.dashboard_models_title.clone(),
609 dashboard_models_subtitle: self.dashboard_models_subtitle.clone(),
610 restore_last_path: self.restore_last_path,
611 };
612 Router::new()
613 // Login / logout (no auth required)
614 .route(
615 &route("/login", &self.base_path),
616 axum::routing::get(login_get).post(login_post),
617 )
618 .route(
619 &route("/logout", &self.base_path),
620 axum::routing::get(logout_handler),
621 )
622 // Index + CRUD routes (all require staff session)
623 .route(
624 &route("", &self.base_path),
625 axum::routing::get(handlers::list::index),
626 )
627 .route(
628 &route("/", &self.base_path),
629 axum::routing::get(handlers::list::index),
630 )
631 .route(
632 &route("/{table}/", &self.base_path),
633 axum::routing::get(handlers::list::list),
634 )
635 .route(
636 &route("/{table}/new", &self.base_path),
637 axum::routing::get(handlers::crud::new_form).post(handlers::crud::create),
638 )
639 .route(
640 &route("/{table}/action", &self.base_path),
641 post(handlers::actions::run_action),
642 )
643 // Phase 2: fragment-only rows endpoint (search/sort/filter/paginate)
644 .route(
645 &route("/{table}/rows", &self.base_path),
646 axum::routing::get(handlers::list::rows_fragment),
647 )
648 // gaps2 #11 round 2: toggle a column's visibility on
649 // the persisted per-table prefs.
650 .route(
651 &route("/{table}/columns/{column}/toggle", &self.base_path),
652 post(handlers::list::toggle_column_visibility),
653 )
654 // Filter dialog fragment
655 .route(
656 &route("/{table}/filter-dialog", &self.base_path),
657 axum::routing::get(handlers::list::filter_dialog_handler),
658 )
659 // Phase 2: new-record sheet (create mode)
660 .route(
661 &route("/{table}/new-sheet", &self.base_path),
662 axum::routing::get(handlers::sheet::new_sheet),
663 )
664 // Phase 2: delete confirm dialog fragment
665 .route(
666 &route("/{table}/{id}/_confirm-delete", &self.base_path),
667 axum::routing::get(handlers::sheet::confirm_delete_dialog),
668 )
669 // Phase 2: sheet fragments (preview + edit)
670 .route(
671 &route("/{table}/{id}/sheet", &self.base_path),
672 axum::routing::get(handlers::sheet::preview_sheet),
673 )
674 .route(
675 &route("/{table}/{id}/edit-sheet", &self.base_path),
676 axum::routing::get(handlers::sheet::edit_sheet_handler),
677 )
678 .route(
679 &route("/{table}/{id}", &self.base_path),
680 axum::routing::get(handlers::crud::detail),
681 )
682 .route(
683 &route("/{table}/{id}/edit", &self.base_path),
684 axum::routing::get(handlers::crud::edit_form).post(handlers::crud::update),
685 )
686 // Phase 2: create via sheet (POST)
687 .route(
688 &route("/{table}/create", &self.base_path),
689 axum::routing::post(handlers::sheet::sheet_create),
690 )
691 // Phase 2: DELETE method for HTMX delete button
692 .route(
693 &route("/{table}/{id}", &self.base_path),
694 axum::routing::delete(handlers::crud::htmx_delete),
695 )
696 .route(
697 &route("/{table}/{id}/delete", &self.base_path),
698 post(handlers::crud::delete),
699 )
700 // Phase 3: per-key action dispatch
701 .route(
702 &route("/{table}/actions/{key}", &self.base_path),
703 axum::routing::post(handlers::actions::dispatch_action),
704 )
705 // Phase 3: FK/M2M async picker endpoints
706 .route(
707 &route("/api/{table}/{field}/options/resolve", &self.base_path),
708 axum::routing::get(handlers::fk_picker::fk_options_resolve),
709 )
710 .route(
711 &route("/api/{table}/{field}/options", &self.base_path),
712 axum::routing::get(handlers::fk_picker::fk_options),
713 )
714 // Phase 3: inline cell edit
715 .route(
716 &route("/{table}/{id}/cell/{field}/edit", &self.base_path),
717 axum::routing::get(handlers::inline_edit::cell_edit_get),
718 )
719 .route(
720 &route("/{table}/{id}/cell/{field}", &self.base_path),
721 axum::routing::post(handlers::inline_edit::cell_edit_post),
722 )
723 // Password change for models with password_field set
724 .route(
725 &route("/{table}/{id}/change-password", &self.base_path),
726 axum::routing::post(handlers::sheet::change_password_handler),
727 )
728 // Phase 4: user prefs
729 .route(
730 &route("/api/prefs", &self.base_path),
731 axum::routing::get(handlers::prefs::get_prefs_handler)
732 .put(handlers::prefs::put_prefs_handler),
733 )
734 // Phase 4: audit history
735 .route(
736 &route("/{table}/{id}/history", &self.base_path),
737 axum::routing::get(handlers::history::history_handler),
738 )
739 // Phase 4: dashboard
740 .route(
741 &route("/api/dashboard/catalog", &self.base_path),
742 axum::routing::get(handlers::dashboard::dashboard_catalog),
743 )
744 .route(
745 &route("/api/dashboard/layout", &self.base_path),
746 axum::routing::get(handlers::dashboard::dashboard_layout_get)
747 .put(handlers::dashboard::dashboard_layout_put),
748 )
749 .route(
750 &route("/api/dashboard/widgets/{key}/data", &self.base_path),
751 axum::routing::get(handlers::dashboard::dashboard_widget_data),
752 )
753 // gaps2 #36: EasyMDE markdown-editor image upload. Staff-gated
754 // (no `{table}` — a media upload isn't scoped to one model), and
755 // stores through the ambient `umbral::storage` seam. Returns
756 // `{ "url": ... }` for the editor's `imageUploadFunction`.
757 .route(
758 &route("/upload-image", &self.base_path),
759 post(handlers::upload::upload_image),
760 )
761 // Phase 4: command palette fragment + global record search
762 .route(
763 &route("/api/palette", &self.base_path),
764 axum::routing::get(handlers::palette::palette_fragment),
765 )
766 .route(
767 &route("/api/palette/search", &self.base_path),
768 axum::routing::get(handlers::palette::palette_search),
769 )
770 // Static admin.css is mounted by the framework via
771 // `static_files()` — no manual route needed here.
772 .with_state(state)
773 }
774
775 fn route_paths(&self) -> Vec<umbral::routes::RouteSpec> {
776 // Companion list to `routes()` — surfaced by the dev-mode
777 // default 404 page. Each entry pairs a path pattern with the
778 // HTTP methods it accepts; keep in sync with the `.route(...)`
779 // calls above. Mismatch is "stale route list," not a routing
780 // bug.
781 use umbral::routes::RouteSpec;
782 // Method shorthands — each constructed once and `clone()`d per
783 // entry so the source list stays one-line-per-route.
784 let g = || vec!["GET"];
785 let p = || vec!["POST"];
786 let gp = || vec!["GET", "POST"];
787 let gpd = || vec!["GET", "POST", "DELETE"];
788 let gput = || vec!["GET", "PUT"];
789 vec![
790 RouteSpec::new(&route("", &self.base_path), g()),
791 RouteSpec::new(&route("/", &self.base_path), g()),
792 RouteSpec::new(&route("/login", &self.base_path), gp()),
793 RouteSpec::new(&route("/logout", &self.base_path), g()),
794 RouteSpec::new(&route("/{table}/", &self.base_path), g()),
795 RouteSpec::new(&route("/{table}/new", &self.base_path), gp()),
796 RouteSpec::new(&route("/{table}/action", &self.base_path), p()),
797 RouteSpec::new(&route("/{table}/rows", &self.base_path), g()),
798 RouteSpec::new(&route("/{table}/filter-dialog", &self.base_path), g()),
799 RouteSpec::new(&route("/{table}/new-sheet", &self.base_path), g()),
800 RouteSpec::new(&route("/{table}/create", &self.base_path), p()),
801 RouteSpec::new(&route("/{table}/{id}", &self.base_path), gpd()),
802 RouteSpec::new(&route("/{table}/{id}/edit", &self.base_path), gp()),
803 RouteSpec::new(&route("/{table}/{id}/edit-sheet", &self.base_path), g()),
804 RouteSpec::new(&route("/{table}/{id}/sheet", &self.base_path), g()),
805 RouteSpec::new(&route("/{table}/{id}/delete", &self.base_path), p()),
806 RouteSpec::new(
807 &route("/{table}/{id}/_confirm-delete", &self.base_path),
808 g(),
809 ),
810 RouteSpec::new(&route("/{table}/{id}/history", &self.base_path), g()),
811 RouteSpec::new(
812 &route("/{table}/{id}/change-password", &self.base_path),
813 p(),
814 ),
815 RouteSpec::new(&route("/{table}/{id}/cell/{field}", &self.base_path), p()),
816 RouteSpec::new(
817 &route("/{table}/{id}/cell/{field}/edit", &self.base_path),
818 g(),
819 ),
820 RouteSpec::new(&route("/{table}/actions/{key}", &self.base_path), p()),
821 RouteSpec::new(&route("/api/{table}/{field}/options", &self.base_path), g()),
822 RouteSpec::new(
823 &route("/api/{table}/{field}/options/resolve", &self.base_path),
824 g(),
825 ),
826 RouteSpec::new(&route("/api/prefs", &self.base_path), gput()),
827 RouteSpec::new(&route("/upload-image", &self.base_path), p()),
828 RouteSpec::new(&route("/api/palette", &self.base_path), g()),
829 RouteSpec::new(&route("/api/palette/search", &self.base_path), g()),
830 RouteSpec::new(&route("/api/dashboard/catalog", &self.base_path), g()),
831 RouteSpec::new(&route("/api/dashboard/layout", &self.base_path), gput()),
832 RouteSpec::new(
833 &route("/api/dashboard/widgets/{key}/data", &self.base_path),
834 g(),
835 ),
836 ]
837 }
838
839 fn on_ready(&self, _ctx: &umbral::plugin::AppContext) -> Result<(), umbral::plugin::PluginError> {
840 // Tables are produced by the migration engine off
841 // `Self::models()` — same path as every other plugin's models.
842 // No bootstrap DDL here.
843 Ok(())
844 }
845}
846
847// =========================================================================
848// Sidebar context helpers.
849//
850// Every handler that renders the authenticated shell calls `sidebar_apps`
851// to pass the nav tree into the template.
852// =========================================================================
853
854// =========================================================================
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 #[test]
861 fn admin_model_defaults() {
862 let m = AdminModel::new("post");
863 assert_eq!(m.get_list_per_page(), 25);
864 assert!(m.inlines.is_empty());
865 assert!(m.label.is_none());
866 assert!(m.icon.is_none());
867 }
868
869 #[test]
870 fn admin_config_alias_compiles() {
871 // The type alias must be identical to AdminModel at the Rust level.
872 let _: AdminConfig = AdminModel::new("test");
873 }
874
875 #[test]
876 fn static_files_use_unified_static_url() {
877 // The embedded admin assets now mount on the unified `/static/admin/…`
878 // pipeline URL (default `static_url`), not the legacy `/admin/static/…`.
879 let files = AdminPlugin::default().static_files();
880 let paths: Vec<&str> = files.iter().map(|f| f.url_path).collect();
881 assert!(
882 paths.contains(&"/static/admin/admin.css"),
883 "admin.css should mount at /static/admin/admin.css, got {paths:?}"
884 );
885 assert!(
886 paths.contains(&"/static/admin/admin.js"),
887 "admin.js should mount at /static/admin/admin.js, got {paths:?}"
888 );
889 // Both still ship non-trivial embedded bytes (zero-config preserved).
890 for f in &files {
891 assert!(
892 f.body.len() > 100,
893 "{} should ship embedded bytes, got {} bytes",
894 f.url_path,
895 f.body.len()
896 );
897 }
898 }
899
900 #[test]
901 fn static_dirs_maps_admin_namespace_to_existing_assets_dir() {
902 let dirs = AdminPlugin::default().static_dirs();
903 assert_eq!(dirs.len(), 1, "admin contributes exactly one static dir");
904 let dir = &dirs[0];
905 assert_eq!(dir.namespace, "admin");
906 // The source dir actually exists on disk and holds the css/js the
907 // embedded route serves — so `collect_static` has real files to gather.
908 assert!(
909 dir.source_dir.join("admin.css").is_file(),
910 "{} should contain admin.css",
911 dir.source_dir.display()
912 );
913 assert!(
914 dir.source_dir.join("admin.js").is_file(),
915 "{} should contain admin.js",
916 dir.source_dir.display()
917 );
918 }
919}