Skip to main content

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}