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("This will PERMANENTLY delete the selected rows. They cannot be restored. Continue?")
355    }
356
357    /// The action key (URL-safe identifier).
358    pub fn key(&self) -> &str {
359        &self.key
360    }
361}
362
363/// Compute the effective bulk-action set for a changelist render or
364/// action dispatch (gaps2 #35).
365///
366/// For a soft-delete model, the admin auto-injects the trash workflow
367/// actions on top of whatever the developer configured:
368///   - In the LIVE view (`trash == false`): nothing extra — the
369///     developer's `delete_selected` already soft-deletes (moves rows
370///     to trash) because `DynQuerySet::delete` honours `soft_delete`.
371///   - In the TRASH view (`trash == true`): the per-row edit/delete
372///     affordances don't apply, so we surface **Restore selected** and
373///     **Delete permanently** instead. The developer's own actions are
374///     dropped in trash view to keep the action set unambiguous.
375///
376/// A non-soft-delete model returns its configured actions unchanged, so
377/// existing installs see zero behavioural difference.
378pub(crate) fn effective_actions(
379    configured: &[Action],
380    soft_delete: bool,
381    trash: bool,
382) -> Vec<Action> {
383    if !soft_delete {
384        return configured.to_vec();
385    }
386    if trash {
387        vec![Action::restore_selected(), Action::delete_permanently()]
388    } else {
389        configured.to_vec()
390    }
391}
392
393fn is_action_key_char(c: char) -> bool {
394    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'
395}
396
397// =========================================================================
398// InlineModel (phase 2 stub)
399// =========================================================================
400
401/// How an inline's children are laid out on the parent change form.
402///
403/// `Tabular` (the default) renders the children as a `<table>` — one
404/// column per displayed field, one row per child, a tabular layout.
405/// `Stacked` renders each child as a vertical sub-form, a stacked
406/// layout, which reads better when a child has many fields.
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
408pub enum InlineKind {
409    /// Children as table rows (default).
410    #[default]
411    Tabular,
412    /// Each child as a vertical sub-form.
413    Stacked,
414}
415
416impl InlineKind {
417    /// The lowercase template tag (`"tabular"` / `"stacked"`) the form
418    /// template branches on.
419    pub(crate) fn as_str(self) -> &'static str {
420        match self {
421            InlineKind::Tabular => "tabular",
422            InlineKind::Stacked => "stacked",
423        }
424    }
425}
426
427/// Data shape for a related-model inline editor.
428///
429/// Declare it on a parent [`AdminModel`] via [`AdminModel::inlines`] to
430/// edit a child model's reverse-FK rows right on the parent's change
431/// form — add new children, edit existing ones, and delete them, all
432/// saved **atomically** with the parent (one transaction; any child
433/// failure rolls the whole save back).
434///
435/// The minimal form — `InlineModel { model, fk_field, list_display }` —
436/// still constructs because the remaining fields carry [`Default`]
437/// values (`kind: Tabular`, `extra: 1`, `can_delete: true`,
438/// `readonly_fields: []`). Prefer the [`InlineModel::new`] constructor
439/// for the common case.
440#[derive(Debug, Clone)]
441pub struct InlineModel {
442    /// Child SQL table name (the model whose rows are edited inline).
443    pub model: String,
444    /// FK column on the child that points back at the parent table.
445    pub fk_field: String,
446    /// Child columns to surface as editable fields / table columns.
447    pub list_display: Vec<String>,
448    /// Tabular (default) vs stacked layout.
449    pub kind: InlineKind,
450    /// Number of blank "add a child" rows to render (default 1).
451    pub extra: usize,
452    /// Whether each child row carries a DELETE checkbox (default true).
453    pub can_delete: bool,
454    /// Child columns rendered read-only (never written back).
455    pub readonly_fields: Vec<String>,
456}
457
458impl Default for InlineModel {
459    fn default() -> Self {
460        Self {
461            model: String::new(),
462            fk_field: String::new(),
463            list_display: Vec::new(),
464            kind: InlineKind::Tabular,
465            extra: 1,
466            can_delete: true,
467            readonly_fields: Vec::new(),
468        }
469    }
470}
471
472impl InlineModel {
473    /// Build an inline for `model` (the child table) whose `fk_field`
474    /// column points back at the parent, surfacing `list_display` as
475    /// the editable columns. Other knobs default
476    /// (`Tabular` / `extra = 1` / `can_delete = true`).
477    pub fn new(
478        model: impl Into<String>,
479        fk_field: impl Into<String>,
480        list_display: &[&str],
481    ) -> Self {
482        Self {
483            model: model.into(),
484            fk_field: fk_field.into(),
485            list_display: list_display.iter().map(|s| s.to_string()).collect(),
486            ..Default::default()
487        }
488    }
489
490    /// Set the layout (tabular vs stacked). Chainable.
491    pub fn kind(mut self, kind: InlineKind) -> Self {
492        self.kind = kind;
493        self
494    }
495
496    /// Set how many blank add-rows to render. Chainable.
497    pub fn extra(mut self, extra: usize) -> Self {
498        self.extra = extra;
499        self
500    }
501
502    /// Toggle the per-row DELETE checkbox. Chainable.
503    pub fn can_delete(mut self, can_delete: bool) -> Self {
504        self.can_delete = can_delete;
505        self
506    }
507
508    /// Mark child columns read-only on the inline. Chainable.
509    pub fn readonly_fields(mut self, fields: &[&str]) -> Self {
510        self.readonly_fields = fields.iter().map(|s| s.to_string()).collect();
511        self
512    }
513}
514
515// =========================================================================
516// AdminModel
517// =========================================================================
518
519/// Per-model admin customization. Build via [`Self::new`] + chainable methods.
520#[derive(Clone, Debug)]
521pub struct AdminModel {
522    pub(crate) table: String,
523    pub(crate) list_display: Vec<String>,
524    pub(crate) list_filter: Vec<String>,
525    pub(crate) search_fields: Vec<String>,
526    pub(crate) ordering: Vec<String>,
527    pub(crate) actions: Vec<Action>,
528    pub(crate) readonly_fields: Vec<String>,
529    pub(crate) list_per_page: usize,
530    pub(crate) inlines: Vec<InlineModel>,
531    pub(crate) label: Option<String>,
532    pub(crate) icon: Option<String>,
533    /// Fields that support double-click inline edit in the DataTable.
534    pub(crate) inline_edit_fields: Vec<String>,
535    /// Optional per-column CSS widths rendered as `<col style="width: ...">`.
536    /// Each entry is `(column_name, css_width)` e.g. `("title", "40%")`.
537    pub(crate) column_widths: Vec<(String, String)>,
538    /// When set, this column carries an argon2 password hash and should
539    /// never be rendered as a plain input. The admin will:
540    /// - Hide the column on edit forms (implicitly noform for the column).
541    /// - Show a "Change password" button on the edit sheet that opens
542    ///   a dedicated dialog.
543    /// - On create forms, render a "Password" + "Confirm password" pair
544    ///   that hashes the value on save.
545    pub(crate) password_field: Option<String>,
546}
547
548/// Names of sensitive columns that are always read-only by default.
549/// Any column whose name matches one of these patterns is added to
550/// `readonly_fields` automatically even if not explicitly listed.
551/// Pattern: exact match OR prefix match for `secret`.
552pub(crate) fn is_sensitive_column(name: &str) -> bool {
553    matches!(name, "password_hash" | "password" | "salt") || name.starts_with("secret")
554}
555
556impl AdminModel {
557    pub fn new(table: impl Into<String>) -> Self {
558        Self {
559            table: table.into(),
560            list_display: Vec::new(),
561            list_filter: Vec::new(),
562            search_fields: Vec::new(),
563            ordering: Vec::new(),
564            actions: Vec::new(),
565            readonly_fields: Vec::new(),
566            list_per_page: 25,
567            inlines: Vec::new(),
568            label: None,
569            icon: None,
570            inline_edit_fields: Vec::new(),
571            column_widths: Vec::new(),
572            password_field: None,
573        }
574    }
575
576    pub fn list_display(mut self, fields: &[&str]) -> Self {
577        self.list_display = fields.iter().map(|s| s.to_string()).collect();
578        self
579    }
580
581    pub fn list_filter(mut self, fields: &[&str]) -> Self {
582        self.list_filter = fields.iter().map(|s| s.to_string()).collect();
583        self
584    }
585
586    pub fn search_fields(mut self, fields: &[&str]) -> Self {
587        self.search_fields = fields.iter().map(|s| s.to_string()).collect();
588        self
589    }
590
591    pub fn ordering(mut self, fields: &[&str]) -> Self {
592        self.ordering = fields.iter().map(|s| s.to_string()).collect();
593        self
594    }
595
596    pub fn actions(mut self, actions: Vec<Action>) -> Self {
597        self.actions = actions;
598        self
599    }
600
601    pub fn readonly_fields(mut self, fields: &[&str]) -> Self {
602        self.readonly_fields = fields.iter().map(|s| s.to_string()).collect();
603        self
604    }
605
606    /// Set per-column CSS widths for the DataTable `<colgroup>`.
607    ///
608    /// Each entry is `(column_name, css_width)`.  The width is rendered as
609    /// `<col style="width: {css_width}">` so you can use any valid CSS value:
610    /// `"40%"`, `"120px"`, `"10rem"`, etc.
611    ///
612    /// # Example
613    /// ```rust,ignore
614    /// AdminModel::new("post")
615    ///     .column_widths(&[("title", "40%"), ("author", "120px")])
616    /// ```
617    pub fn column_widths(mut self, widths: &[(&str, &str)]) -> Self {
618        self.column_widths = widths
619            .iter()
620            .map(|(col, w)| (col.to_string(), w.to_string()))
621            .collect();
622        self
623    }
624
625    /// Return the merged readonly set: explicit `readonly_fields` plus any
626    /// columns whose names match the built-in sensitive defaults
627    /// (`password_hash`, `password`, `salt`, `secret*`).
628    pub fn effective_readonly_fields<'a>(&'a self, all_columns: &[&'a str]) -> Vec<&'a str> {
629        let mut set: std::collections::HashSet<&str> =
630            self.readonly_fields.iter().map(|s| s.as_str()).collect();
631        for col in all_columns {
632            if is_sensitive_column(col) {
633                set.insert(col);
634            }
635        }
636        set.into_iter().collect()
637    }
638
639    pub fn list_per_page(mut self, n: usize) -> Self {
640        self.list_per_page = n;
641        self
642    }
643
644    pub fn inlines(mut self, inlines: Vec<InlineModel>) -> Self {
645        self.inlines = inlines;
646        self
647    }
648
649    pub fn label(mut self, label: impl Into<String>) -> Self {
650        self.label = Some(label.into());
651        self
652    }
653
654    pub fn icon(mut self, icon: impl Into<String>) -> Self {
655        self.icon = Some(icon.into());
656        self
657    }
658
659    /// Enable double-click inline cell edit for these columns in the DataTable.
660    pub fn inline_edit_fields(mut self, fields: &[&str]) -> Self {
661        self.inline_edit_fields = fields.iter().map(|s| s.to_string()).collect();
662        self
663    }
664
665    /// Mark `column` as carrying an argon2 password hash.
666    ///
667    /// The admin will never render this column as a plain text input.
668    /// Instead:
669    /// - Create forms receive a "Password" + "Confirm password" pair that
670    ///   hashes the value before writing.
671    /// - Edit forms show a "Change password" button that opens a dedicated
672    ///   dialog (separate request).
673    ///
674    /// Set this on `AuthUser` or any model that carries a password column:
675    ///
676    /// ```ignore
677    /// AdminModel::new("auth_user").password_field("password_hash")
678    /// ```
679    pub fn password_field(mut self, column: impl Into<String>) -> Self {
680        self.password_field = Some(column.into());
681        self
682    }
683
684    pub fn table(&self) -> &str {
685        &self.table
686    }
687
688    pub fn get_list_per_page(&self) -> usize {
689        self.list_per_page
690    }
691
692    /// Expose `column_widths` as a slice for use in templates and tests.
693    pub fn get_column_widths(&self) -> &[(String, String)] {
694        &self.column_widths
695    }
696}
697
698// =========================================================================
699// Backwards-compat alias
700// =========================================================================
701
702pub type AdminConfig = AdminModel;