rustio_admin/admin/types.rs
1//! The admin's data vocabulary. Kept separate from rendering and
2//! handlers so changes here ripple out predictably.
3
4// `for_testing[_failing_list]` + the PanicOps/FailingOps fixtures
5// are part of the admin's test surface but no in-tree test exercises
6// them yet (the legacy admin/macro_tests etc. land in a follow-up).
7// Keep them gated behind cfg(test) elsewhere; allow dead inside that
8// gate.
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use crate::auth::{
14 DefaultPasswordPolicy, DefaultRecoveryPolicy, MfaPolicy, SharedPasswordPolicy,
15 SharedRecoveryPolicy,
16};
17use crate::email::{LogMailer, SharedMailer};
18use crate::error::Result;
19use crate::http::FormData;
20use crate::orm::{Db, Value};
21
22pub(crate) type CreateResult<'a> =
23 Pin<Box<dyn Future<Output = Result<std::result::Result<i64, Vec<String>>>> + Send + 'a>>;
24
25pub(crate) type UpdateResult<'a> =
26 Pin<Box<dyn Future<Output = Result<std::result::Result<(), Vec<String>>>> + Send + 'a>>;
27
28// ---------------------------------------------------------------------------
29// User profile extension API
30// ---------------------------------------------------------------------------
31
32// public:
33/// One labeled section rendered in the project-extension area of the
34/// built-in user profile page (admin/user_view.html — `{% block
35/// project_user_fields %}`). A project's extension closure returns
36/// `Vec<UserProfileSection>` so it can contribute multiple disjoint
37/// areas in a single registration.
38#[derive(Debug, Clone, serde::Serialize)]
39pub struct UserProfileSection {
40 pub label: String,
41 pub rows: Vec<UserProfileRow>,
42}
43
44// public:
45/// One key-value row inside a [`UserProfileSection`]. Both fields are
46/// `String` so projects can format whatever shape they need. Rendered
47/// escaped — pass plain text; for arbitrary HTML, projects override
48/// the template block instead.
49#[derive(Debug, Clone, serde::Serialize)]
50pub struct UserProfileRow {
51 pub label: String,
52 pub value: String,
53}
54
55/// The boxed-closure shape stored on `Admin`. `pub(crate)` because
56/// projects use the generic [`Admin::user_profile_extension`] builder
57/// method and never have to name this directly.
58pub(crate) type UserProfileExtensionFn =
59 Arc<dyn Fn(Db, crate::auth::UserProfile) -> UserProfileExtensionFuture + Send + Sync + 'static>;
60
61pub(crate) type UserProfileExtensionFuture =
62 Pin<Box<dyn Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static>>;
63
64// public:
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum FieldType {
68 I32,
69 I64,
70 Bool,
71 String,
72 DateTime,
73 OptionalI64,
74 OptionalString,
75 OptionalDateTime,
76}
77
78impl FieldType {
79 // public:
80 pub fn widget(&self) -> &'static str {
81 match self {
82 FieldType::Bool => "checkbox",
83 FieldType::DateTime | FieldType::OptionalDateTime => "datetime",
84 FieldType::I32 | FieldType::I64 | FieldType::OptionalI64 => "number",
85 FieldType::String | FieldType::OptionalString => "text",
86 }
87 }
88
89 // public:
90 pub fn nullable(&self) -> bool {
91 matches!(
92 self,
93 FieldType::OptionalI64 | FieldType::OptionalString | FieldType::OptionalDateTime
94 )
95 }
96}
97
98// public:
99#[derive(Debug, Clone)]
100pub struct AdminField {
101 pub name: &'static str,
102 pub label: &'static str,
103 pub field_type: FieldType,
104 pub editable: bool,
105 pub relation: Option<AdminRelation>,
106 /// Closed list of allowed string values for this field. When
107 /// `Some`, the form layer renders a `<select>` with one option per
108 /// entry. The values double as labels (raw, not humanised) per
109 /// the "no invented content" rule.
110 pub choices: Option<&'static [&'static str]>,
111}
112
113// public:
114#[derive(Debug, Clone)]
115pub struct AdminRelation {
116 pub target_model: &'static str,
117 pub display_field: Option<&'static str>,
118 /// `true` for many-to-many relations (form renders
119 /// `<select multiple>`), `false` for the default belongs-to
120 /// (single `<select>`). Macro emits `false`; consumers that want
121 /// M2M behaviour must hand-set this until the macro learns a
122 /// `#[rustio(many_to_many)]` attribute.
123 pub multi: bool,
124}
125
126// public:
127/// What the `#[derive(RustioAdmin)]` macro produces for each struct.
128pub trait AdminModel: Send + Sync + 'static {
129 const ADMIN_NAME: &'static str;
130 const DISPLAY_NAME: &'static str;
131 const SINGULAR_NAME: &'static str;
132 const FIELDS: &'static [AdminField];
133
134 /// Render one row for the list page (column → display string).
135 fn display_values(&self) -> Vec<(String, String)>;
136
137 /// Populate a new instance from an HTTP form. Returns a list of
138 /// validation errors if anything was wrong.
139 fn from_form(form: &FormData) -> std::result::Result<Self, Vec<String>>
140 where
141 Self: Sized;
142
143 /// A stable label for one instance (used on the delete confirm page).
144 fn object_label(&self) -> String;
145
146 fn id(&self) -> i64;
147
148 fn values_to_update(&self) -> Vec<(&'static str, Value)>;
149}
150
151// public:
152/// Runtime metadata about one admin-registered model. Captures both
153/// the [`AdminModel`] static surface and the [`super::ModelAdmin`]
154/// customisation values at registration time, so handlers read every
155/// per-model knob from this struct instead of re-resolving traits.
156pub struct AdminEntry {
157 pub admin_name: &'static str,
158 pub display_name: &'static str,
159 pub singular_name: &'static str,
160 /// SQL table name. For user-registered models this is `<M as Model>::TABLE`;
161 /// for the synthetic core User entry it's `"rustio_users"`.
162 pub table: &'static str,
163 pub fields: &'static [AdminField],
164 /// `true` only for framework-owned entries (currently just `User`).
165 pub core: bool,
166 /// `ModelAdmin::list_display()`. Empty → use every column on
167 /// `fields`; non-empty → use exactly the listed names in order.
168 pub list_display: &'static [&'static str],
169 /// `ModelAdmin::list_filter()`. Empty by default.
170 pub list_filter: &'static [&'static str],
171 /// `ModelAdmin::search_fields()`. Empty by default.
172 pub search_fields: &'static [&'static str],
173 /// `ModelAdmin::ordering()`. Strings parsed via
174 /// [`super::modeladmin::parse_order_spec`].
175 pub ordering: &'static [&'static str],
176 /// `ModelAdmin::list_per_page()`. Default 50.
177 pub list_per_page: usize,
178 /// `ModelAdmin::readonly_fields()`. Empty by default.
179 pub readonly_fields: &'static [&'static str],
180 /// `ModelAdmin::fieldsets()`. Empty → fall back to the
181 /// framework's name-heuristic grouping.
182 pub fieldsets: &'static [super::modeladmin::Fieldset],
183 /// `ModelAdmin::bulk_actions()`. Empty by default — the bulk bar
184 /// only renders the framework's built-in Delete.
185 pub bulk_actions: &'static [super::modeladmin::BulkAction],
186 pub(crate) ops: Arc<dyn AdminOps>,
187}
188
189// public:
190/// Per-request options for [`AdminOps::list`]. Empty / `None` fields
191/// mean "framework default": no ordering override falls back to
192/// `id DESC` inside the runtime, no filters skips the WHERE clause,
193/// no limit fetches every row.
194#[derive(Debug, Clone, Default)]
195pub struct ListOpts {
196 /// Validated `(column, dir)` pairs to apply as `ORDER BY`. The
197 /// column name is bound to the model's `M::COLUMNS` set inside
198 /// the runtime, so callers can pass user-supplied names without
199 /// SQL-injection risk.
200 pub ordering: Vec<(String, super::modeladmin::SortDir)>,
201 /// `(column, value)` pairs applied as `WHERE col::text = $N`.
202 /// Cast to text so the comparison matches the same string-shape
203 /// semantics the in-memory pre-P10 filter used for bool / int /
204 /// timestamp columns.
205 pub filters: Vec<(String, String)>,
206 /// Free-text search: `(term, columns)`. The runtime emits
207 /// `WHERE (col1::text ILIKE $N OR col2::text ILIKE $N OR …)`
208 /// with `$N = '%term%'`. An empty `term` or empty `columns`
209 /// leaves the WHERE alone.
210 pub search: Option<(String, Vec<String>)>,
211 /// `LIMIT $N` for the data query. The COUNT(*) query never
212 /// applies it. `None` → no limit.
213 pub limit: Option<i64>,
214 /// `OFFSET $N` for the data query. `None` or `Some(0)` → no offset.
215 pub offset: Option<i64>,
216}
217
218// public:
219/// Result of [`AdminOps::list`]: the requested page plus the total
220/// row count under the same WHERE clause (so handlers can render
221/// pagination footers without a separate query).
222#[derive(Debug, Default)]
223pub struct ListPage {
224 pub rows: Vec<ListRow>,
225 pub total: i64,
226}
227
228/// Type-erased CRUD operations. The `Admin::model::<M>()` call captures
229/// a concrete `M: AdminModel + Model` and hides it behind this trait so
230/// the router can treat every model uniformly. The single live impl is
231/// [`super::ops::ConcreteOps<M>`].
232pub(crate) trait AdminOps: Send + Sync {
233 fn list<'a>(
234 &'a self,
235 db: &'a Db,
236 opts: ListOpts,
237 ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>>;
238
239 fn find_row<'a>(
240 &'a self,
241 db: &'a Db,
242 id: i64,
243 ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>>;
244
245 fn create<'a>(&'a self, db: &'a Db, form: &'a FormData) -> CreateResult<'a>;
246
247 fn update<'a>(&'a self, db: &'a Db, id: i64, form: &'a FormData) -> UpdateResult<'a>;
248
249 fn delete<'a>(
250 &'a self,
251 db: &'a Db,
252 id: i64,
253 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
254
255 fn object_label<'a>(
256 &'a self,
257 db: &'a Db,
258 id: i64,
259 ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>>;
260
261 /// Run a project-defined bulk action against the supplied row
262 /// ids. Called once per submission with the full id list, so the
263 /// implementation can choose between a single bulk SQL update or
264 /// a per-row loop. The default impl returns `BadRequest` with the
265 /// action name embedded — projects override to match on `name`
266 /// and apply the work; an unknown name surfaces as a clear error
267 /// page rather than a silent no-op.
268 ///
269 /// Note: the framework's built-in `delete` action is **not**
270 /// dispatched through here. It runs through the cascade-aware
271 /// `/bulk_delete` route which calls `delete()` per row. Override
272 /// `delete` instead if you need custom delete semantics.
273 fn execute_bulk_action<'a>(
274 &'a self,
275 _db: &'a Db,
276 name: &'a str,
277 _ids: &'a [i64],
278 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
279 let owned = name.to_string();
280 Box::pin(async move {
281 Err(crate::error::Error::BadRequest(format!(
282 "bulk action `{owned}` has no project handler — override \
283 AdminOps::execute_bulk_action on this model to implement it"
284 )))
285 })
286 }
287}
288
289// public:
290/// A row as shown on the list page.
291#[derive(Debug)]
292pub struct ListRow {
293 pub id: i64,
294 pub cells: Vec<String>,
295 /// Optional link target per cell, parallel to `cells`. When
296 /// `Some`, the renderer wraps that cell's content in an
297 /// `<a href="/admin/{admin_name}/{id}/edit">…</a>` so foreign-key
298 /// columns become click-throughs to the related row. Populated by
299 /// the post-list hydration pass in `handlers::hydrate_fk_cells`;
300 /// `ConcreteOps::list` always emits a parallel vector of `None` of
301 /// matching length so callers that skip hydration still satisfy
302 /// the parallel-vector invariant.
303 pub cell_links: Vec<Option<CellLink>>,
304}
305
306// public:
307/// One resolved foreign-key cell. The renderer turns this into
308/// `<a href="/admin/{admin_name}/{id}/edit">…</a>` around the cell's
309/// display label.
310#[derive(Debug, Clone)]
311pub struct CellLink {
312 /// Target model's admin slug (e.g. `"categories"` for `Category`).
313 pub admin_name: String,
314 /// Target row id.
315 pub id: i64,
316}
317
318// public:
319/// The raw field values used to pre-fill the edit form.
320#[derive(Debug)]
321pub struct EditRow {
322 #[allow(dead_code)]
323 pub id: i64,
324 pub values: Vec<(String, String)>,
325}
326
327// public:
328/// Per-project admin branding. Defaults are RustIO-flavoured;
329/// projects override via [`Admin::site_branding`].
330#[derive(Clone, Debug)]
331pub struct SiteBranding {
332 pub site_title: String,
333 pub site_header: String,
334 pub index_title: String,
335 pub footer_copyright: String,
336 /// DNS-shape string available to project handlers; not surfaced in
337 /// any framework template.
338 pub domain: String,
339}
340
341impl Default for SiteBranding {
342 fn default() -> Self {
343 Self {
344 site_title: "RustIO administration".into(),
345 site_header: "RustIO administration".into(),
346 index_title: "Site administration".into(),
347 footer_copyright: format!("RustIO {}", env!("CARGO_PKG_VERSION")),
348 domain: "rustio.local".into(),
349 }
350 }
351}
352
353// public:
354/// Project-level override patch for the admin chrome palette.
355///
356/// `admin.css` is the single source of truth for the framework's design
357/// tokens (light defaults, dark mode, semantic surfaces, typography
358/// scale, …). `AdminTheme` is **purely a patch layer**: every field is
359/// `Option<String>` and defaults to `None`, meaning *“don’t override —
360/// let the stylesheet decide.”* Out of the box the framework emits no
361/// inline `<style>` block at all.
362///
363/// Set a field — usually via the fluent builder methods or
364/// [`Admin::accent_color`] — to inject a `--rio-*` custom-property
365/// override on every page. Overrides apply across `data-rio-theme`
366/// states (system / light / dark) by emitting a multi-state selector
367/// after `admin.css`, so they win cascade ties without `!important`.
368///
369/// Values are hex (`#rrggbb` or `rrggbb`); the leading `#` is
370/// auto-normalised at construction. Malformed input is rejected at
371/// override time rather than panicking — the admin path never breaks
372/// over a config typo.
373#[derive(Clone, Debug, Default, PartialEq, Eq)]
374pub struct AdminTheme {
375 pub accent: Option<String>,
376 pub bg: Option<String>,
377 pub surface: Option<String>,
378 pub text: Option<String>,
379 pub text_muted: Option<String>,
380 pub border: Option<String>,
381}
382
383impl AdminTheme {
384 // public:
385 /// New empty patch — no overrides emitted, `admin.css` wins.
386 pub fn new() -> Self {
387 Self::default()
388 }
389
390 // internal:
391 /// `true` when at least one field is set. Used by the renderer to
392 /// decide whether to emit the inline `<style>` block at all.
393 pub fn has_overrides(&self) -> bool {
394 self.accent.is_some()
395 || self.bg.is_some()
396 || self.surface.is_some()
397 || self.text.is_some()
398 || self.text_muted.is_some()
399 || self.border.is_some()
400 }
401
402 // public:
403 /// Override `--rio-accent`. Hex form, `#` optional.
404 pub fn accent(mut self, color: impl Into<String>) -> Self {
405 self.accent = Some(normalise_hex(color));
406 self
407 }
408
409 // public:
410 /// Override `--rio-bg` (page canvas).
411 pub fn bg(mut self, color: impl Into<String>) -> Self {
412 self.bg = Some(normalise_hex(color));
413 self
414 }
415
416 // public:
417 /// Override `--rio-surface` (cards, topbar, sidebar, table body).
418 pub fn surface(mut self, color: impl Into<String>) -> Self {
419 self.surface = Some(normalise_hex(color));
420 self
421 }
422
423 // public:
424 /// Override `--rio-text` (body text colour).
425 pub fn text(mut self, color: impl Into<String>) -> Self {
426 self.text = Some(normalise_hex(color));
427 self
428 }
429
430 // public:
431 /// Override `--rio-text-muted` (secondary text, breadcrumb links).
432 pub fn text_muted(mut self, color: impl Into<String>) -> Self {
433 self.text_muted = Some(normalise_hex(color));
434 self
435 }
436
437 // public:
438 /// Override `--rio-border` (default divider, card outline).
439 pub fn border(mut self, color: impl Into<String>) -> Self {
440 self.border = Some(normalise_hex(color));
441 self
442 }
443}
444
445// public:
446/// Builder for the admin. Register models with `.model::<M>()`, then
447/// hand it to the router via `register_admin_routes`.
448pub struct Admin {
449 pub(crate) entries: Vec<AdminEntry>,
450 pub(crate) site_branding: SiteBranding,
451 pub(crate) user_profile_ext: Option<UserProfileExtensionFn>,
452 pub(crate) theme: AdminTheme,
453 /// The outbound-mail handle. Defaults to [`LogMailer`]; projects
454 /// override via [`Admin::mailer`]. R1+ recovery flows
455 /// (`DESIGN_RECOVERY.md` §12) read this to dispatch reset emails;
456 /// `auth::recovery::issue_reset_token` (R1 commit #7) reads it
457 /// at runtime. Held as `Arc<dyn Mailer>` so cloning the field is
458 /// a single reference-count bump and the field stays trivially
459 /// Send + Sync (the trait's supertraits are `Send + Sync`).
460 pub(crate) mailer: SharedMailer,
461 /// Whether [`Admin::mailer`] has been called to replace the
462 /// default `LogMailer`. Used by the R1 commit #9 strict-mailer
463 /// boot guard to decide whether the project's deployment is
464 /// production-ready (see [`Admin::has_custom_mailer`]). Flipped
465 /// to `true` on any call to `mailer(...)`, including a call
466 /// that re-registers a `LogMailer` instance — explicit operator
467 /// override is enough; the framework does not peek inside the
468 /// trait object.
469 pub(crate) mailer_overridden: bool,
470 /// The active password policy. Defaults to
471 /// [`DefaultPasswordPolicy::new`] (`min_len = 10`); projects
472 /// override via [`Admin::password_policy`]. Read by R1's reset
473 /// consume flow (commit #7) and the corrected `do_password_change`
474 /// (commit #11) so a single source of truth governs every
475 /// password write across the framework. Held as
476 /// `Arc<dyn PasswordPolicy>` for the same reason as the mailer
477 /// above (cheap clone, Send + Sync).
478 pub(crate) password_policy: SharedPasswordPolicy,
479 /// The active recovery policy: reset-token TTL, rate-limit
480 /// shape, strict-mailer boot guard, public-site-URL derivation.
481 /// Defaults to [`DefaultRecoveryPolicy::new`]; projects override
482 /// via [`Admin::recovery_policy`]. Read by R1's recovery
483 /// handlers (commits #7–#9). Held as `Arc<dyn RecoveryPolicy>`
484 /// — same architectural pattern as the mailer and the password
485 /// policy above.
486 pub(crate) recovery_policy: SharedRecoveryPolicy,
487 /// The active MFA enforcement policy. Defaults to
488 /// [`MfaPolicy::Optional`]; projects opt into enforcement via
489 /// [`Admin::require_mfa`]. Plain `Copy` enum (no `Arc`
490 /// indirection) — the four variants encode every operator
491 /// choice: rejected (`Disabled`), opt-in (`Optional`),
492 /// universal (`Required`), or per-role (`RequiredForRoles`).
493 /// The `login_guard` consults this field after successful
494 /// password verification (R3 commit #15); this commit lands
495 /// the data, the routing follows.
496 pub(crate) mfa_policy: MfaPolicy,
497}
498
499impl Default for Admin {
500 fn default() -> Self {
501 Self::new()
502 }
503}
504
505impl Admin {
506 // public:
507 /// Constructs a new `Admin` with the framework's core entries
508 /// pre-seeded. The only core entry is `User`; project models are
509 /// added on top via [`Self::model`]. The outbound mailer
510 /// defaults to [`LogMailer`] — safe for dev / CI / testing,
511 /// **not suitable for production** (recovery emails are written
512 /// to `log::info!` instead of being sent). Projects opt into a
513 /// real mailer via [`Self::mailer`].
514 pub fn new() -> Self {
515 Self {
516 entries: vec![core_user_entry()],
517 site_branding: SiteBranding::default(),
518 user_profile_ext: None,
519 theme: AdminTheme::default(),
520 mailer: Arc::new(LogMailer),
521 mailer_overridden: false,
522 password_policy: Arc::new(DefaultPasswordPolicy::new()),
523 recovery_policy: Arc::new(DefaultRecoveryPolicy::new()),
524 mfa_policy: MfaPolicy::default(),
525 }
526 }
527
528 // public:
529 /// Override the default RustIO branding.
530 pub fn site_branding(mut self, branding: SiteBranding) -> Self {
531 self.site_branding = branding;
532 self
533 }
534
535 // public:
536 /// Read-only access to the active branding.
537 pub fn branding(&self) -> &SiteBranding {
538 &self.site_branding
539 }
540
541 // public:
542 /// Set the admin chrome's accent colour. Hex form, with or without
543 /// the leading `#` (`"#1e6ba8"` and `"1e6ba8"` both work). Replaces
544 /// any prior accent override; other [`AdminTheme`] fields are
545 /// left untouched.
546 pub fn accent_color(mut self, color: impl Into<String>) -> Self {
547 self.theme.accent = Some(normalise_hex(color));
548 self
549 }
550
551 // public:
552 /// Replace the entire admin chrome palette patch in one call. See
553 /// [`AdminTheme`] for the field-by-field contract.
554 pub fn theme(mut self, theme: AdminTheme) -> Self {
555 self.theme = theme;
556 self
557 }
558
559 // public:
560 /// Read-only access to the configured accent colour, if any. `None`
561 /// means *“no override — admin.css owns it”*.
562 pub fn accent(&self) -> Option<&str> {
563 self.theme.accent.as_deref()
564 }
565
566 // public:
567 /// Read-only access to the active theme override patch.
568 pub fn active_theme(&self) -> &AdminTheme {
569 &self.theme
570 }
571
572 // public:
573 /// Replace the outbound mailer. Closes the
574 /// documented-but-unimplemented gap from 0.4.0 where the doc
575 /// comments described this method while the `Admin` struct had
576 /// no mailer field; landed in 0.5.0 alongside the R1 recovery
577 /// pipeline that consumes it (`DESIGN_RECOVERY.md` §10.3).
578 ///
579 /// Typical project wiring:
580 ///
581 /// ```ignore
582 /// use std::sync::Arc;
583 /// let admin = Admin::new()
584 /// .mailer(Arc::new(MyProjectMailer::new(/* SES, Mailgun, … */)));
585 /// ```
586 ///
587 /// The framework imposes no transport. Anything that implements
588 /// the [`crate::email::Mailer`] trait (which is `Send + Sync`
589 /// and async-friendly) plugs in here. R1's recovery flow reads
590 /// this via [`Self::active_mailer`] and dispatches reset
591 /// emails through it.
592 pub fn mailer(mut self, mailer: SharedMailer) -> Self {
593 self.mailer = mailer;
594 self.mailer_overridden = true;
595 self
596 }
597
598 // public:
599 /// Read-only access to the registered mailer. Returns a borrow
600 /// of the `Arc` so handlers can `.clone()` it cheaply when they
601 /// need to move the handle into an async future. Always returns
602 /// a live mailer — `Admin::new()` seeds [`LogMailer`] as the
603 /// default, so this never returns `None`.
604 pub fn active_mailer(&self) -> &SharedMailer {
605 &self.mailer
606 }
607
608 // public:
609 /// Whether the project explicitly called [`Self::mailer`] to
610 /// register a mailer. Returns `false` for `Admin::new()` (the
611 /// framework's `LogMailer` default is in place); flips to `true`
612 /// on any subsequent call to `mailer(...)`, regardless of the
613 /// concrete type supplied — the framework trusts the operator's
614 /// explicit override.
615 ///
616 /// Read by the R1 strict-mailer boot guard: when
617 /// `RecoveryPolicy::strict_mailer_required() == true` and this
618 /// returns `false`, `register_admin_routes` panics at startup
619 /// rather than registering the recovery routes against a
620 /// production-unsafe default mailer.
621 pub fn has_custom_mailer(&self) -> bool {
622 self.mailer_overridden
623 }
624
625 // public:
626 /// Replace the active password policy. R1 ships with the
627 /// length-only [`DefaultPasswordPolicy`] (`min_len = 10`);
628 /// production deployments commonly override to 12+, and
629 /// regulated deployments may ship a full custom impl with breach
630 /// blocklists or organisational complexity rules
631 /// (`DESIGN_RECOVERY.md` §13).
632 ///
633 /// Typical project wiring:
634 ///
635 /// ```ignore
636 /// use std::sync::Arc;
637 /// use rustio_admin::auth::DefaultPasswordPolicy;
638 ///
639 /// let admin = Admin::new()
640 /// .password_policy(Arc::new(DefaultPasswordPolicy::with_min_len(16)));
641 /// ```
642 pub fn password_policy(mut self, policy: SharedPasswordPolicy) -> Self {
643 self.password_policy = policy;
644 self
645 }
646
647 // public:
648 /// Read-only access to the registered password policy. Returns
649 /// a borrow of the `Arc` so handlers can `.clone()` it cheaply
650 /// when needed. Always returns a live policy — `Admin::new()`
651 /// seeds [`DefaultPasswordPolicy`] so this never returns `None`.
652 pub fn active_password_policy(&self) -> &SharedPasswordPolicy {
653 &self.password_policy
654 }
655
656 // public:
657 /// Replace the active recovery policy. R1 ships with
658 /// [`DefaultRecoveryPolicy`] (TTL 1h, request 5/15min, consume
659 /// 10/5min, strict-mailer guard off); production deployments
660 /// commonly opt into the strict guard via
661 /// `with_strict_mailer_required(true)` after registering a real
662 /// mailer (`DESIGN_RECOVERY.md` §12).
663 ///
664 /// Typical project wiring:
665 ///
666 /// ```ignore
667 /// use std::sync::Arc;
668 /// use rustio_admin::auth::DefaultRecoveryPolicy;
669 ///
670 /// let admin = Admin::new()
671 /// .recovery_policy(Arc::new(
672 /// DefaultRecoveryPolicy::new()
673 /// .with_strict_mailer_required(true),
674 /// ));
675 /// ```
676 pub fn recovery_policy(mut self, policy: SharedRecoveryPolicy) -> Self {
677 self.recovery_policy = policy;
678 self
679 }
680
681 // public:
682 /// Read-only access to the registered recovery policy. Returns
683 /// a borrow of the `Arc`. Always live — `Admin::new()` seeds
684 /// [`DefaultRecoveryPolicy`] so this never returns `None`.
685 pub fn active_recovery_policy(&self) -> &SharedRecoveryPolicy {
686 &self.recovery_policy
687 }
688
689 // public:
690 /// Replace the active MFA enforcement policy. R3 ships with
691 /// [`MfaPolicy::Optional`] as the default — pre-R3 framework
692 /// behaviour, no opt-in required. Production deployments that
693 /// want MFA enforcement opt in via this builder.
694 ///
695 /// **Forward-only enforcement (D6).** Switching to
696 /// [`MfaPolicy::Required`] does NOT retroactively revoke
697 /// existing sessions; the `login_guard` redirects users
698 /// without MFA to `/admin/mfa/enroll` at the next request.
699 /// The pattern mirrors R2's `must_change_password`
700 /// interstitial (`DESIGN_R3_MFA.md` §12.3).
701 ///
702 /// **Boot guard (D1).** When `MfaPolicy != Disabled`, the
703 /// framework refuses to boot if `RUSTIO_SECRET_KEY` is
704 /// unset — the env var is required for AES-256-GCM
705 /// encryption of TOTP secrets at rest. The boot check lands
706 /// in a later R3 commit; this builder records the policy
707 /// without the check.
708 ///
709 /// Typical project wiring:
710 ///
711 /// ```ignore
712 /// use rustio_admin::auth::{MfaPolicy, Role};
713 ///
714 /// // Universal:
715 /// let admin = Admin::new().require_mfa(MfaPolicy::Required);
716 ///
717 /// // Privileged roles only:
718 /// const PRIVILEGED: &[Role] = &[Role::Administrator, Role::Supervisor];
719 /// let admin = Admin::new()
720 /// .require_mfa(MfaPolicy::RequiredForRoles(PRIVILEGED));
721 /// ```
722 pub fn require_mfa(mut self, policy: MfaPolicy) -> Self {
723 self.mfa_policy = policy;
724 self
725 }
726
727 // public:
728 /// Read-only access to the active MFA policy. Returns by
729 /// value — the policy is `Copy`. Always live — `Admin::new()`
730 /// seeds [`MfaPolicy::default`] (`Optional`) so this never
731 /// returns `None`.
732 pub fn active_mfa_policy(&self) -> MfaPolicy {
733 self.mfa_policy
734 }
735
736 // public:
737 pub fn model<M>(mut self) -> Self
738 where
739 M: super::ModelAdmin + crate::orm::Model,
740 {
741 let ops: Arc<dyn AdminOps> = Arc::new(super::ops::ConcreteOps::<M>::new());
742 self.entries.push(AdminEntry {
743 admin_name: M::ADMIN_NAME,
744 display_name: M::DISPLAY_NAME,
745 singular_name: M::SINGULAR_NAME,
746 table: <M as crate::orm::Model>::TABLE,
747 fields: M::FIELDS,
748 core: false,
749 list_display: M::list_display(),
750 list_filter: M::list_filter(),
751 search_fields: M::search_fields(),
752 ordering: M::ordering(),
753 list_per_page: M::list_per_page(),
754 readonly_fields: M::readonly_fields(),
755 fieldsets: M::fieldsets(),
756 bulk_actions: M::bulk_actions(),
757 ops,
758 });
759 self
760 }
761
762 // public:
763 pub fn entries(&self) -> &[AdminEntry] {
764 &self.entries
765 }
766
767 // public:
768 /// Register a project-specific extension that contributes extra
769 /// sections to the built-in user profile page. The closure is
770 /// invoked on every render of `GET /admin/users/:id` (Overview tab);
771 /// it receives the `Db` handle and the loaded
772 /// [`crate::auth::UserProfile`] (no `password_hash`) and returns a
773 /// `Vec<UserProfileSection>`. Sections render in the order returned,
774 /// immediately after the core profile show-grid.
775 ///
776 /// Zero-config baseline: don't call this method, and the extension
777 /// area stays empty. Projects that need richer layout than key-value
778 /// rows override the `{% block project_user_fields %}` template
779 /// block in `templates/admin/user_view.html` instead.
780 pub fn user_profile_extension<F, Fut>(mut self, ext: F) -> Self
781 where
782 F: Fn(Db, crate::auth::UserProfile) -> Fut + Send + Sync + 'static,
783 Fut: Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static,
784 {
785 self.user_profile_ext = Some(Arc::new(move |db, user| Box::pin(ext(db, user))));
786 self
787 }
788
789 /// Internal accessor — handlers fetch the registered extension
790 /// closure (if any) here. Used by `admin/builtin.rs` (P6.b).
791 #[allow(dead_code)]
792 pub(crate) fn user_profile_ext(&self) -> Option<&UserProfileExtensionFn> {
793 self.user_profile_ext.as_ref()
794 }
795
796 // public:
797 pub fn find(&self, admin_name: &str) -> Option<&AdminEntry> {
798 self.entries.iter().find(|e| e.admin_name == admin_name)
799 }
800
801 // public:
802 /// Register the canonical (add/change/delete/view) permissions for
803 /// every model. Call during startup after `init_tables`.
804 pub async fn seed_permissions(&self, db: &crate::orm::Db) -> crate::error::Result<()> {
805 for entry in &self.entries {
806 let singular = entry.singular_name.to_ascii_lowercase();
807 crate::auth::register_model_permissions(db, entry.admin_name, &singular).await?;
808 }
809 Ok(())
810 }
811}
812
813// -------------------------------------------------------------------------
814// Core User entry — synthetic, route-only stub
815// -------------------------------------------------------------------------
816//
817// Every project's admin index lists `Users` so operators can navigate
818// to the bespoke `/admin/users/*` pages owned by `admin::builtin`. The
819// `User` entry is built directly here rather than implementing
820// `AdminModel` on a placeholder struct: the auth subsystem already
821// owns the live `/admin/users` page with its own logic; routing
822// through generic CRUD here would spawn a duplicate page.
823
824const CORE_USER_FIELDS: &[AdminField] = &[
825 AdminField {
826 name: "id",
827 label: "id",
828 field_type: FieldType::I64,
829 editable: false,
830 relation: None,
831 choices: None,
832 },
833 AdminField {
834 name: "email",
835 label: "email",
836 field_type: FieldType::String,
837 editable: true,
838 relation: None,
839 choices: None,
840 },
841 AdminField {
842 name: "password_hash",
843 label: "password_hash",
844 field_type: FieldType::String,
845 editable: false,
846 relation: None,
847 choices: None,
848 },
849 AdminField {
850 name: "role",
851 label: "role",
852 field_type: FieldType::String,
853 editable: true,
854 relation: None,
855 choices: None,
856 },
857 AdminField {
858 name: "is_active",
859 label: "is_active",
860 field_type: FieldType::Bool,
861 editable: true,
862 relation: None,
863 choices: None,
864 },
865 AdminField {
866 name: "created_at",
867 label: "created_at",
868 field_type: FieldType::DateTime,
869 editable: false,
870 relation: None,
871 choices: None,
872 },
873];
874
875/// Normalise a user-supplied colour string to `#rrggbb` form. Accepts
876/// both `"#1e6ba8"` and `"1e6ba8"`; trims whitespace; does NOT validate
877/// that the body is hex (that's the renderer's job, where invalid
878/// values fall back to the framework default rather than panic). The
879/// `format!()` adds back exactly one leading `#`.
880pub(crate) fn normalise_hex(input: impl Into<String>) -> String {
881 let raw = input.into();
882 let trimmed = raw.trim().trim_start_matches('#');
883 format!("#{trimmed}")
884}
885
886fn core_user_entry() -> AdminEntry {
887 AdminEntry {
888 admin_name: "users",
889 display_name: "Users",
890 singular_name: "User",
891 table: "rustio_users",
892 fields: CORE_USER_FIELDS,
893 core: true,
894 list_display: &[],
895 list_filter: &[],
896 search_fields: &[],
897 ordering: &["-id"],
898 list_per_page: 50,
899 readonly_fields: &[],
900 fieldsets: &[],
901 bulk_actions: &[],
902 ops: Arc::new(CoreUserOps),
903 }
904}
905
906/// Route-only stub for the synthetic User entry. The live
907/// `/admin/users` page is wired separately by `admin::builtin`, so
908/// every method here returns a dedicated error rather than silently
909/// half-working. If the generic admin ever routes to this, the error
910/// makes the misuse obvious.
911struct CoreUserOps;
912
913fn core_user_route_error() -> crate::error::Error {
914 crate::error::Error::Internal(
915 "the core User entry is route-only — use the dedicated /admin/users page".into(),
916 )
917}
918
919impl AdminOps for CoreUserOps {
920 fn list<'a>(
921 &'a self,
922 _db: &'a Db,
923 _opts: ListOpts,
924 ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>> {
925 Box::pin(async { Err(core_user_route_error()) })
926 }
927
928 fn find_row<'a>(
929 &'a self,
930 _db: &'a Db,
931 _id: i64,
932 ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
933 Box::pin(async { Err(core_user_route_error()) })
934 }
935
936 fn create<'a>(&'a self, _db: &'a Db, _form: &'a FormData) -> CreateResult<'a> {
937 Box::pin(async { Err(core_user_route_error()) })
938 }
939
940 fn update<'a>(&'a self, _db: &'a Db, _id: i64, _form: &'a FormData) -> UpdateResult<'a> {
941 Box::pin(async { Err(core_user_route_error()) })
942 }
943
944 fn delete<'a>(
945 &'a self,
946 _db: &'a Db,
947 _id: i64,
948 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
949 Box::pin(async { Err(core_user_route_error()) })
950 }
951
952 fn object_label<'a>(
953 &'a self,
954 _db: &'a Db,
955 _id: i64,
956 ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>> {
957 Box::pin(async { Err(core_user_route_error()) })
958 }
959}
960
961// Test fixtures (PanicOps / FailingOps + AdminEntry::for_testing*) live
962// with the legacy `admin/macro_tests.rs` etc. that haven't been ported
963// yet. Re-add them here when the first in-tree test needs them.
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use crate::auth::{PasswordPolicy, PasswordPolicyError};
969
970 #[test]
971 fn admin_new_installs_default_password_policy() {
972 let admin = Admin::new();
973 // Default floor is 10 (per DESIGN_RECOVERY.md §13.2).
974 assert_eq!(admin.active_password_policy().min_length(), 10);
975 // Sanity: a 9-char password is rejected, a 10-char is accepted.
976 assert!(admin
977 .active_password_policy()
978 .validate("nine_char")
979 .is_err());
980 assert!(admin
981 .active_password_policy()
982 .validate("ten_chars_")
983 .is_ok());
984 }
985
986 #[test]
987 fn admin_password_policy_overrides_default() {
988 struct StubPolicy;
989 impl PasswordPolicy for StubPolicy {
990 fn validate(&self, _candidate: &str) -> std::result::Result<(), PasswordPolicyError> {
991 Err(PasswordPolicyError::Custom("stub rejected".into()))
992 }
993 fn min_length(&self) -> usize {
994 99
995 }
996 }
997
998 let admin = Admin::new().password_policy(Arc::new(StubPolicy));
999 assert_eq!(admin.active_password_policy().min_length(), 99);
1000 let err = admin
1001 .active_password_policy()
1002 .validate("anything-at-all-here")
1003 .unwrap_err();
1004 assert_eq!(err, PasswordPolicyError::Custom("stub rejected".into()));
1005 }
1006
1007 #[test]
1008 fn admin_new_installs_default_recovery_policy() {
1009 let admin = Admin::new();
1010 let p = admin.active_recovery_policy();
1011 // Locked defaults from DESIGN_RECOVERY.md §17.
1012 assert_eq!(p.reset_token_ttl(), chrono::Duration::hours(1));
1013 assert_eq!(
1014 p.request_rate_limit(),
1015 (5, std::time::Duration::from_secs(15 * 60))
1016 );
1017 assert_eq!(
1018 p.consume_rate_limit(),
1019 (10, std::time::Duration::from_secs(5 * 60))
1020 );
1021 assert!(!p.strict_mailer_required());
1022 }
1023
1024 #[test]
1025 fn admin_new_has_no_custom_mailer() {
1026 let admin = Admin::new();
1027 assert!(!admin.has_custom_mailer());
1028 }
1029
1030 #[test]
1031 fn admin_mailer_builder_flips_override_flag() {
1032 // Even when the override happens to register another LogMailer,
1033 // the explicit call is what the strict-mailer guard reads.
1034 let admin = Admin::new().mailer(Arc::new(crate::email::LogMailer));
1035 assert!(admin.has_custom_mailer());
1036 }
1037
1038 #[test]
1039 fn admin_recovery_policy_overrides_default() {
1040 use crate::auth::RecoveryPolicy;
1041
1042 struct StubRecoveryPolicy;
1043 impl RecoveryPolicy for StubRecoveryPolicy {
1044 fn reset_token_ttl(&self) -> chrono::Duration {
1045 chrono::Duration::hours(2)
1046 }
1047 fn request_rate_limit(&self) -> (u32, std::time::Duration) {
1048 (1, std::time::Duration::from_secs(60))
1049 }
1050 fn consume_rate_limit(&self) -> (u32, std::time::Duration) {
1051 (2, std::time::Duration::from_secs(120))
1052 }
1053 fn strict_mailer_required(&self) -> bool {
1054 true
1055 }
1056 // public_site_url uses the trait's provided default.
1057 }
1058
1059 let admin = Admin::new().recovery_policy(Arc::new(StubRecoveryPolicy));
1060 let p = admin.active_recovery_policy();
1061 assert_eq!(p.reset_token_ttl(), chrono::Duration::hours(2));
1062 assert_eq!(
1063 p.request_rate_limit(),
1064 (1, std::time::Duration::from_secs(60))
1065 );
1066 assert_eq!(
1067 p.consume_rate_limit(),
1068 (2, std::time::Duration::from_secs(120))
1069 );
1070 assert!(p.strict_mailer_required());
1071 }
1072}