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