Skip to main content

umbral_admin/
config.rs

1//! Per-model admin customization bundles.
2//!
3//! [`AdminModel`] is the admin's equivalent of `umbral_rest::ResourceConfig`.
4//! One config per registered model. Build via [`AdminModel::new`] + chainable
5//! methods, then register with [`crate::AdminPlugin::register`]:
6//!
7//! ```ignore
8//! use umbral_admin::{AdminPlugin, AdminModel, Action};
9//!
10//! AdminPlugin::default()
11//!     .register(
12//!         AdminModel::new("post")
13//!             .list_display(&["title", "author", "published_at"])
14//!             .list_filter(&["published", "author"])
15//!             .search_fields(&["title", "body"])
16//!             .ordering(&["-published_at", "title"])
17//!             .readonly_fields(&["created_at", "id"])
18//!             .actions(vec![Action::delete_selected()]),
19//!     )
20//! ```
21
22use std::future::Future;
23use std::pin::Pin;
24use std::sync::Arc;
25
26use umbral::db::DbPool;
27
28// =========================================================================
29// Action result / invocation types
30// =========================================================================
31
32/// Severity level for toast notifications.
33#[derive(Debug, Clone)]
34pub enum ToastLevel {
35    Info,
36    Success,
37    Warning,
38    Error,
39}
40
41impl ToastLevel {
42    pub fn as_str(&self) -> &'static str {
43        match self {
44            ToastLevel::Info => "info",
45            ToastLevel::Success => "success",
46            ToastLevel::Warning => "warning",
47            ToastLevel::Error => "error",
48        }
49    }
50}
51
52/// The result an action handler returns to the admin runtime.
53///
54/// The runtime encodes each variant as HTMX response directives:
55/// - `Toast` → `HX-Trigger: {"showToast": {...}}`
56/// - `RefreshTable` → rows fragment swap
57/// - `OpenSheet` → `HX-Trigger: {"openSheet": {...}}`
58/// - `Download` → `Content-Disposition: attachment` bytes
59/// - `Redirect` → `HX-Redirect` header
60#[derive(Debug, Clone)]
61pub enum ActionResult {
62    Toast {
63        message: String,
64        level: ToastLevel,
65    },
66    RefreshTable,
67    OpenSheet {
68        table: String,
69        id: i64,
70    },
71    Download {
72        filename: String,
73        content_type: String,
74        bytes: Vec<u8>,
75    },
76    Redirect {
77        url: String,
78    },
79}
80
81/// Visual variant for an action button.
82#[derive(Debug, Clone)]
83pub enum ActionVariant {
84    Default,
85    Danger,
86}
87
88/// Which surfaces an action appears on.
89#[derive(Debug, Clone, PartialEq)]
90pub enum ActionScope {
91    Row,
92    Bulk,
93    Both,
94}
95
96/// Context available to action handlers.
97#[derive(Debug, Clone)]
98pub struct ActionInvocation {
99    /// Selected primary keys as raw strings (matches the model's actual PK
100    /// type — i64, String, or Uuid — without forcing a parse to i64).
101    pub ids: Vec<String>,
102    /// Username of the currently-logged-in staff user.
103    pub username: String,
104    /// SQL table the action was invoked on.
105    pub table: String,
106    /// Ambient backend-aware pool — match on the `DbPool` variants
107    /// (`Sqlite` / `Postgres`) for any escape-hatch raw SQL. New code
108    /// should prefer the ORM (`Model::objects()` / `DynQuerySet`)
109    /// instead of pulling the pool out at all.
110    pub pool: DbPool,
111}
112
113/// Backwards-compatible context type used by phase 1/2 code paths.
114#[derive(Debug, Clone)]
115pub struct AdminContext {
116    pub username: String,
117    pub table: String,
118}
119
120pub(crate) type ActionFuture =
121    Pin<Box<dyn Future<Output = Result<ActionResult, String>> + Send + 'static>>;
122
123pub(crate) type ActionHandlerFn =
124    Arc<dyn Fn(ActionInvocation) -> ActionFuture + Send + Sync + 'static>;
125
126/// A row or bulk admin action.
127///
128/// Build with [`Action::new`]; chain `.danger()`, `.scope()`, `.confirm()`,
129/// `.permission()` to configure. Use [`Action::delete_selected`] for the
130/// built-in bulk-delete.
131#[derive(Clone)]
132pub struct Action {
133    pub(crate) key: String,
134    /// Display label shown in tooltips / overflow menus.
135    pub(crate) label: String,
136    /// Lucide icon name (e.g. "send", "trash-2").
137    pub(crate) icon: String,
138    pub(crate) variant: ActionVariant,
139    pub(crate) scope: ActionScope,
140    /// If `Some`, a confirm dialog is shown before firing.
141    pub(crate) confirm: Option<String>,
142    /// Permission codename to check. `None` = any staff user may invoke.
143    /// When `Some(codename)`, the action handler only runs if the acting
144    /// user holds that codename (directly or via group), or is a superuser.
145    /// No-op when `umbral-permissions` is not installed (gaps2 #79).
146    pub(crate) permission: Option<String>,
147    pub(crate) handler: ActionHandlerFn,
148}
149
150impl std::fmt::Debug for Action {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.debug_struct("Action")
153            .field("key", &self.key)
154            .field("label", &self.label)
155            .field("icon", &self.icon)
156            .finish()
157    }
158}
159
160impl Action {
161    /// Create a new action.
162    ///
163    /// `key` must be ASCII lowercase/digits/underscores/hyphens.
164    pub fn new<F, Fut>(
165        key: impl Into<String>,
166        label: impl Into<String>,
167        icon: impl Into<String>,
168        f: F,
169    ) -> Self
170    where
171        F: Fn(ActionInvocation) -> Fut + Send + Sync + 'static,
172        Fut: Future<Output = Result<ActionResult, String>> + Send + 'static,
173    {
174        let key = key.into();
175        assert!(
176            !key.is_empty() && key.chars().all(is_action_key_char),
177            "Action::new: key {key:?} must be ASCII [a-z0-9_-]"
178        );
179        Action {
180            key,
181            label: label.into(),
182            icon: icon.into(),
183            variant: ActionVariant::Default,
184            scope: ActionScope::Both,
185            confirm: None,
186            permission: None,
187            handler: Arc::new(move |inv| Box::pin(f(inv))),
188        }
189    }
190
191    /// Mark this action as danger variant (red styling).
192    pub fn danger(mut self) -> Self {
193        self.variant = ActionVariant::Danger;
194        self
195    }
196
197    /// Restrict this action to row-only or bulk-only scope.
198    pub fn scope(mut self, scope: ActionScope) -> Self {
199        self.scope = scope;
200        self
201    }
202
203    /// Require a confirm dialog before firing. `message` is shown in the dialog.
204    pub fn confirm(mut self, message: impl Into<String>) -> Self {
205        self.confirm = Some(message.into());
206        self
207    }
208
209    /// Require a permission codename before this action can run (gaps2 #79).
210    ///
211    /// When set, the admin checks that the acting user holds `codename`
212    /// (directly or via a group) before invoking the handler. Superusers
213    /// bypass the check. If `umbral-permissions` is not installed, the check
214    /// is a no-op and any staff user can run the action.
215    ///
216    /// `codename` should be the full composite key your permissions plugin
217    /// uses, e.g. `"blog.publish_post"`.
218    pub fn permission(mut self, codename: impl Into<String>) -> Self {
219        self.permission = Some(codename.into());
220        self
221    }
222
223    /// Built-in bulk-delete. The built-in "Delete selected" action.
224    pub fn delete_selected() -> Self {
225        Self::new(
226            "delete_selected",
227            "Delete selected",
228            "trash-2",
229            |inv| async move {
230                if inv.ids.is_empty() {
231                    return Ok(ActionResult::Toast {
232                        message: "No rows selected.".to_string(),
233                        level: ToastLevel::Info,
234                    });
235                }
236                let Some((_, meta)) = crate::discovery::find_model(&inv.table) else {
237                    return Err(format!("unknown table `{}`", inv.table));
238                };
239                let pk_name = crate::discovery::pk_column(&meta)
240                    .map(|c| c.name.clone())
241                    .unwrap_or_else(|| "id".to_string());
242                match umbral::orm::DynQuerySet::for_meta(&meta)
243                    .filter_in_strings(&pk_name, &inv.ids)
244                    .delete()
245                    .await
246                {
247                    Ok(deleted) => Ok(ActionResult::Toast {
248                        message: format!("Deleted {deleted} row(s)."),
249                        level: ToastLevel::Success,
250                    }),
251                    Err(e) => {
252                        tracing::error!(error = %e, "admin: delete_selected failed");
253                        Err("database error during delete".to_string())
254                    }
255                }
256            },
257        )
258        .danger()
259        .scope(ActionScope::Bulk)
260        .confirm("This will permanently delete the selected rows. Continue?")
261    }
262
263    /// Built-in "Restore selected" for soft-delete models (gaps2 #35).
264    ///
265    /// Clears `deleted_at` for the selected rows via
266    /// [`DynQuerySet::restore`], moving them back out of the trash into
267    /// the live changelist. Auto-injected for `soft_delete` models (see
268    /// [`effective_actions`]); a non-soft-delete model never sees it.
269    pub fn restore_selected() -> Self {
270        Self::new(
271            "restore_selected",
272            "Restore selected",
273            "archive-restore",
274            |inv| async move {
275                if inv.ids.is_empty() {
276                    return Ok(ActionResult::Toast {
277                        message: "No rows selected.".to_string(),
278                        level: ToastLevel::Info,
279                    });
280                }
281                let Some((_, meta)) = crate::discovery::find_model(&inv.table) else {
282                    return Err(format!("unknown table `{}`", inv.table));
283                };
284                let pk_name = crate::discovery::pk_column(&meta)
285                    .map(|c| c.name.clone())
286                    .unwrap_or_else(|| "id".to_string());
287                // `with_deleted()` so the PK filter can address the
288                // trashed rows; `restore()` then clears `deleted_at`.
289                match umbral::orm::DynQuerySet::for_meta(&meta)
290                    .with_deleted()
291                    .filter_in_strings(&pk_name, &inv.ids)
292                    .restore()
293                    .await
294                {
295                    Ok(restored) => Ok(ActionResult::Toast {
296                        message: format!("Restored {restored} row(s)."),
297                        level: ToastLevel::Success,
298                    }),
299                    Err(e) => {
300                        tracing::error!(error = %e, "admin: restore_selected failed");
301                        Err("database error during restore".to_string())
302                    }
303                }
304            },
305        )
306        .scope(ActionScope::Bulk)
307    }
308
309    /// Built-in "Delete permanently" for soft-delete models (gaps2 #35).
310    ///
311    /// Issues a real `DELETE` via [`DynQuerySet::hard_delete`], bypassing
312    /// the soft-delete stamp so the row leaves the table entirely (gone
313    /// even from `with_deleted()`). Behind a confirm interstitial.
314    /// Auto-injected for `soft_delete` models; a non-soft-delete model
315    /// never sees it (its `delete_selected` already deletes for real).
316    pub fn delete_permanently() -> Self {
317        Self::new(
318            "delete_permanently",
319            "Delete permanently",
320            "trash-2",
321            |inv| async move {
322                if inv.ids.is_empty() {
323                    return Ok(ActionResult::Toast {
324                        message: "No rows selected.".to_string(),
325                        level: ToastLevel::Info,
326                    });
327                }
328                let Some((_, meta)) = crate::discovery::find_model(&inv.table) else {
329                    return Err(format!("unknown table `{}`", inv.table));
330                };
331                let pk_name = crate::discovery::pk_column(&meta)
332                    .map(|c| c.name.clone())
333                    .unwrap_or_else(|| "id".to_string());
334                match umbral::orm::DynQuerySet::for_meta(&meta)
335                    .hard_delete()
336                    .with_deleted()
337                    .filter_in_strings(&pk_name, &inv.ids)
338                    .delete()
339                    .await
340                {
341                    Ok(deleted) => Ok(ActionResult::Toast {
342                        message: format!("Permanently deleted {deleted} row(s)."),
343                        level: ToastLevel::Success,
344                    }),
345                    Err(e) => {
346                        tracing::error!(error = %e, "admin: delete_permanently failed");
347                        Err("database error during permanent delete".to_string())
348                    }
349                }
350            },
351        )
352        .danger()
353        .scope(ActionScope::Bulk)
354        .confirm(
355            "This will PERMANENTLY delete the selected rows. They cannot be restored. Continue?",
356        )
357    }
358
359    /// The action key (URL-safe identifier).
360    pub fn key(&self) -> &str {
361        &self.key
362    }
363}
364
365/// Compute the effective bulk-action set for a changelist render or
366/// action dispatch (gaps2 #35).
367///
368/// For a soft-delete model, the admin auto-injects the trash workflow
369/// actions on top of whatever the developer configured:
370///   - In the LIVE view (`trash == false`): nothing extra — the
371///     developer's `delete_selected` already soft-deletes (moves rows
372///     to trash) because `DynQuerySet::delete` honours `soft_delete`.
373///   - In the TRASH view (`trash == true`): the per-row edit/delete
374///     affordances don't apply, so we surface **Restore selected** and
375///     **Delete permanently** instead. The developer's own actions are
376///     dropped in trash view to keep the action set unambiguous.
377///
378/// A non-soft-delete model returns its configured actions unchanged, so
379/// existing installs see zero behavioural difference.
380pub(crate) fn effective_actions(
381    configured: &[Action],
382    soft_delete: bool,
383    trash: bool,
384) -> Vec<Action> {
385    if !soft_delete {
386        return configured.to_vec();
387    }
388    if trash {
389        vec![Action::restore_selected(), Action::delete_permanently()]
390    } else {
391        configured.to_vec()
392    }
393}
394
395fn is_action_key_char(c: char) -> bool {
396    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'
397}
398
399// =========================================================================
400// InlineModel (phase 2 stub)
401// =========================================================================
402
403/// How an inline's children are laid out on the parent change form.
404///
405/// `Tabular` (the default) renders the children as a `<table>` — one
406/// column per displayed field, one row per child, a tabular layout.
407/// `Stacked` renders each child as a vertical sub-form, a stacked
408/// layout, which reads better when a child has many fields.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
410pub enum InlineKind {
411    /// Children as table rows (default).
412    #[default]
413    Tabular,
414    /// Each child as a vertical sub-form.
415    Stacked,
416}
417
418impl InlineKind {
419    /// The lowercase template tag (`"tabular"` / `"stacked"`) the form
420    /// template branches on.
421    pub(crate) fn as_str(self) -> &'static str {
422        match self {
423            InlineKind::Tabular => "tabular",
424            InlineKind::Stacked => "stacked",
425        }
426    }
427}
428
429/// Data shape for a related-model inline editor.
430///
431/// Declare it on a parent [`AdminModel`] via [`AdminModel::inlines`] to
432/// edit a child model's reverse-FK rows right on the parent's change
433/// form — add new children, edit existing ones, and delete them, all
434/// saved **atomically** with the parent (one transaction; any child
435/// failure rolls the whole save back).
436///
437/// The minimal form — `InlineModel { model, fk_field, list_display }` —
438/// still constructs because the remaining fields carry [`Default`]
439/// values (`kind: Tabular`, `extra: 1`, `can_delete: true`,
440/// `readonly_fields: []`). Prefer the [`InlineModel::new`] constructor
441/// for the common case.
442#[derive(Debug, Clone)]
443pub struct InlineModel {
444    /// Child SQL table name (the model whose rows are edited inline).
445    pub model: String,
446    /// FK column on the child that points back at the parent table.
447    pub fk_field: String,
448    /// Child columns to surface as editable fields / table columns.
449    pub list_display: Vec<String>,
450    /// Tabular (default) vs stacked layout.
451    pub kind: InlineKind,
452    /// Number of blank "add a child" rows to render (default 1).
453    pub extra: usize,
454    /// Whether each child row carries a DELETE checkbox (default true).
455    pub can_delete: bool,
456    /// Child columns rendered read-only (never written back).
457    pub readonly_fields: Vec<String>,
458}
459
460impl Default for InlineModel {
461    fn default() -> Self {
462        Self {
463            model: String::new(),
464            fk_field: String::new(),
465            list_display: Vec::new(),
466            kind: InlineKind::Tabular,
467            extra: 1,
468            can_delete: true,
469            readonly_fields: Vec::new(),
470        }
471    }
472}
473
474impl InlineModel {
475    /// Build an inline for `model` (the child table) whose `fk_field`
476    /// column points back at the parent, surfacing `list_display` as
477    /// the editable columns. Other knobs default
478    /// (`Tabular` / `extra = 1` / `can_delete = true`).
479    pub fn new(
480        model: impl Into<String>,
481        fk_field: impl Into<String>,
482        list_display: &[&str],
483    ) -> Self {
484        Self {
485            model: model.into(),
486            fk_field: fk_field.into(),
487            list_display: list_display.iter().map(|s| s.to_string()).collect(),
488            ..Default::default()
489        }
490    }
491
492    /// Set the layout (tabular vs stacked). Chainable.
493    pub fn kind(mut self, kind: InlineKind) -> Self {
494        self.kind = kind;
495        self
496    }
497
498    /// Set how many blank add-rows to render. Chainable.
499    pub fn extra(mut self, extra: usize) -> Self {
500        self.extra = extra;
501        self
502    }
503
504    /// Toggle the per-row DELETE checkbox. Chainable.
505    pub fn can_delete(mut self, can_delete: bool) -> Self {
506        self.can_delete = can_delete;
507        self
508    }
509
510    /// Mark child columns read-only on the inline. Chainable.
511    pub fn readonly_fields(mut self, fields: &[&str]) -> Self {
512        self.readonly_fields = fields.iter().map(|s| s.to_string()).collect();
513        self
514    }
515}
516
517// =========================================================================
518// AdminModel
519// =========================================================================
520
521/// Per-model admin customization. Build via [`Self::new`] + chainable methods.
522#[derive(Clone, Debug)]
523pub struct AdminModel {
524    pub(crate) table: String,
525    pub(crate) list_display: Vec<String>,
526    pub(crate) list_filter: Vec<String>,
527    pub(crate) search_fields: Vec<String>,
528    pub(crate) ordering: Vec<String>,
529    pub(crate) actions: Vec<Action>,
530    pub(crate) readonly_fields: Vec<String>,
531    pub(crate) list_per_page: usize,
532    pub(crate) inlines: Vec<InlineModel>,
533    pub(crate) label: Option<String>,
534    pub(crate) icon: Option<String>,
535    /// Fields that support double-click inline edit in the DataTable.
536    pub(crate) inline_edit_fields: Vec<String>,
537    /// Optional per-column CSS widths rendered as `<col style="width: ...">`.
538    /// Each entry is `(column_name, css_width)` e.g. `("title", "40%")`.
539    pub(crate) column_widths: Vec<(String, String)>,
540    /// When set, this column carries an argon2 password hash and should
541    /// never be rendered as a plain input. The admin will:
542    /// - Hide the column on edit forms (implicitly noform for the column).
543    /// - Show a "Change password" button on the edit sheet that opens
544    ///   a dedicated dialog.
545    /// - On create forms, render a "Password" + "Confirm password" pair
546    ///   that hashes the value on save.
547    pub(crate) password_field: Option<String>,
548}
549
550/// Names of sensitive columns that are always read-only by default.
551/// Any column whose name matches one of these patterns is added to
552/// `readonly_fields` automatically even if not explicitly listed.
553/// Pattern: exact match OR prefix match for `secret`.
554pub(crate) fn is_sensitive_column(name: &str) -> bool {
555    matches!(name, "password_hash" | "password" | "salt") || name.starts_with("secret")
556}
557
558impl AdminModel {
559    pub fn new(table: impl Into<String>) -> Self {
560        Self {
561            table: table.into(),
562            list_display: Vec::new(),
563            list_filter: Vec::new(),
564            search_fields: Vec::new(),
565            ordering: Vec::new(),
566            actions: Vec::new(),
567            readonly_fields: Vec::new(),
568            list_per_page: 25,
569            inlines: Vec::new(),
570            label: None,
571            icon: None,
572            inline_edit_fields: Vec::new(),
573            column_widths: Vec::new(),
574            password_field: None,
575        }
576    }
577
578    pub fn list_display(mut self, fields: &[&str]) -> Self {
579        self.list_display = fields.iter().map(|s| s.to_string()).collect();
580        self
581    }
582
583    pub fn list_filter(mut self, fields: &[&str]) -> Self {
584        self.list_filter = fields.iter().map(|s| s.to_string()).collect();
585        self
586    }
587
588    pub fn search_fields(mut self, fields: &[&str]) -> Self {
589        self.search_fields = fields.iter().map(|s| s.to_string()).collect();
590        self
591    }
592
593    pub fn ordering(mut self, fields: &[&str]) -> Self {
594        self.ordering = fields.iter().map(|s| s.to_string()).collect();
595        self
596    }
597
598    pub fn actions(mut self, actions: Vec<Action>) -> Self {
599        self.actions = actions;
600        self
601    }
602
603    pub fn readonly_fields(mut self, fields: &[&str]) -> Self {
604        self.readonly_fields = fields.iter().map(|s| s.to_string()).collect();
605        self
606    }
607
608    /// Set per-column CSS widths for the DataTable `<colgroup>`.
609    ///
610    /// Each entry is `(column_name, css_width)`.  The width is rendered as
611    /// `<col style="width: {css_width}">` so you can use any valid CSS value:
612    /// `"40%"`, `"120px"`, `"10rem"`, etc.
613    ///
614    /// # Example
615    /// ```rust,ignore
616    /// AdminModel::new("post")
617    ///     .column_widths(&[("title", "40%"), ("author", "120px")])
618    /// ```
619    pub fn column_widths(mut self, widths: &[(&str, &str)]) -> Self {
620        self.column_widths = widths
621            .iter()
622            .map(|(col, w)| (col.to_string(), w.to_string()))
623            .collect();
624        self
625    }
626
627    /// Return the merged readonly set: explicit `readonly_fields` plus any
628    /// columns whose names match the built-in sensitive defaults
629    /// (`password_hash`, `password`, `salt`, `secret*`).
630    pub fn effective_readonly_fields<'a>(&'a self, all_columns: &[&'a str]) -> Vec<&'a str> {
631        let mut set: std::collections::HashSet<&str> =
632            self.readonly_fields.iter().map(|s| s.as_str()).collect();
633        for col in all_columns {
634            if is_sensitive_column(col) {
635                set.insert(col);
636            }
637        }
638        set.into_iter().collect()
639    }
640
641    pub fn list_per_page(mut self, n: usize) -> Self {
642        self.list_per_page = n;
643        self
644    }
645
646    pub fn inlines(mut self, inlines: Vec<InlineModel>) -> Self {
647        self.inlines = inlines;
648        self
649    }
650
651    pub fn label(mut self, label: impl Into<String>) -> Self {
652        self.label = Some(label.into());
653        self
654    }
655
656    pub fn icon(mut self, icon: impl Into<String>) -> Self {
657        self.icon = Some(icon.into());
658        self
659    }
660
661    /// Enable double-click inline cell edit for these columns in the DataTable.
662    pub fn inline_edit_fields(mut self, fields: &[&str]) -> Self {
663        self.inline_edit_fields = fields.iter().map(|s| s.to_string()).collect();
664        self
665    }
666
667    /// Mark `column` as carrying an argon2 password hash.
668    ///
669    /// The admin will never render this column as a plain text input.
670    /// Instead:
671    /// - Create forms receive a "Password" + "Confirm password" pair that
672    ///   hashes the value before writing.
673    /// - Edit forms show a "Change password" button that opens a dedicated
674    ///   dialog (separate request).
675    ///
676    /// Set this on `AuthUser` or any model that carries a password column:
677    ///
678    /// ```ignore
679    /// AdminModel::new("auth_user").password_field("password_hash")
680    /// ```
681    pub fn password_field(mut self, column: impl Into<String>) -> Self {
682        self.password_field = Some(column.into());
683        self
684    }
685
686    pub fn table(&self) -> &str {
687        &self.table
688    }
689
690    pub fn get_list_per_page(&self) -> usize {
691        self.list_per_page
692    }
693
694    /// Expose `column_widths` as a slice for use in templates and tests.
695    pub fn get_column_widths(&self) -> &[(String, String)] {
696        &self.column_widths
697    }
698}
699
700// =========================================================================
701// Backwards-compat alias
702// =========================================================================
703
704pub type AdminConfig = AdminModel;