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