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;