Skip to main content

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.
265    ///
266    /// The real public dispatcher is
267    /// [`super::ModelAdmin::execute_bulk_action`];
268    /// [`super::ops::ConcreteOps<M>`] forwards into the model's
269    /// override from this trait method. The
270    /// [`super::types::CoreUserOps`] entry has no project surface
271    /// (the User row is framework-owned), so it returns the
272    /// "no project handler" error verbatim.
273    ///
274    /// Note: the framework's built-in `delete` action is **not**
275    /// dispatched through here. It runs through the cascade-aware
276    /// `/bulk_delete` route which calls `delete()` per row. Override
277    /// `delete` instead if you need custom delete semantics.
278    fn execute_bulk_action<'a>(
279        &'a self,
280        db: &'a Db,
281        name: &'a str,
282        ids: &'a [i64],
283        ctx: &'a super::bulk::BulkActionContext<'a>,
284    ) -> Pin<Box<dyn Future<Output = Result<super::bulk::BulkActionResult>> + Send + 'a>>;
285}
286
287// public:
288/// A row as shown on the list page.
289#[derive(Debug)]
290pub struct ListRow {
291    pub id: i64,
292    pub cells: Vec<String>,
293    /// Optional link target per cell, parallel to `cells`. When
294    /// `Some`, the renderer wraps that cell's content in an
295    /// `<a href="/admin/{admin_name}/{id}/edit">…</a>` so foreign-key
296    /// columns become click-throughs to the related row. Populated by
297    /// the post-list hydration pass in `handlers::hydrate_fk_cells`;
298    /// `ConcreteOps::list` always emits a parallel vector of `None` of
299    /// matching length so callers that skip hydration still satisfy
300    /// the parallel-vector invariant.
301    pub cell_links: Vec<Option<CellLink>>,
302}
303
304// public:
305/// One resolved foreign-key cell. The renderer turns this into
306/// `<a href="/admin/{admin_name}/{id}/edit">…</a>` around the cell's
307/// display label.
308#[derive(Debug, Clone)]
309pub struct CellLink {
310    /// Target model's admin slug (e.g. `"categories"` for `Category`).
311    pub admin_name: String,
312    /// Target row id.
313    pub id: i64,
314}
315
316// public:
317/// The raw field values used to pre-fill the edit form.
318#[derive(Debug)]
319pub struct EditRow {
320    #[allow(dead_code)]
321    pub id: i64,
322    pub values: Vec<(String, String)>,
323}
324
325// public:
326/// Per-project admin branding — the user-facing identity layer.
327///
328/// Production deployments MUST set [`SiteBranding::app_name`] (or
329/// use the [`Admin::app_name`] builder) — the framework name
330/// `RustIO` should never appear as the visible product identity for
331/// end users. RustIO is the vendor; the *deployed application* is
332/// what users see in their inbox, on the login page, and in the
333/// password-reset flow.
334///
335/// Field roles:
336///
337///   - [`app_name`](Self::app_name) — primary user-facing product
338///     identity. Used by every framework-emitted surface that
339///     reaches end users: email subjects + headers, recovery
340///     pages, admin chrome footer, audit summaries.
341///   - [`app_tagline`](Self::app_tagline) — optional secondary
342///     line. Renders as the descriptor under the brand wordmark
343///     in recovery emails. `None` falls back to the framework's
344///     generic "Account security notification" caption.
345///   - [`support_email`](Self::support_email) — optional contact
346///     surfaced in the recovery email footer ("If you didn't
347///     request this, contact <support@…>"). `None` omits the
348///     line.
349///   - [`public_url`](Self::public_url) — canonical public URL
350///     used when composing reset links if the request's
351///     `Host` / `X-Forwarded-Host` derivation is unreliable.
352///     `None` falls back to header-derived URLs.
353///   - [`show_powered_by`](Self::show_powered_by) — opt-in
354///     "Powered by RustIO" credit in the chrome footer + email
355///     footer. Defaults to `false`; the framework name stays
356///     invisible to end users unless the project explicitly
357///     enables this.
358///
359/// Legacy fields ([`site_title`](Self::site_title) /
360/// [`site_header`](Self::site_header) / etc.) predate this
361/// architecture and are still honoured for backwards compatibility,
362/// but their defaults were renamed away from "RustIO administration"
363/// so a zero-config build no longer leaks the framework name. New
364/// code should use the `app_*` fields exclusively.
365#[derive(Clone, Debug)]
366pub struct SiteBranding {
367    /// Primary user-facing product identity. Visible in:
368    /// page chrome (topbar, footer), email subjects + headers,
369    /// audit summaries, recovery pages, login screen.
370    pub app_name: String,
371    /// Optional secondary line for use under the brand wordmark in
372    /// emails / auth surfaces. Examples: "Operational library
373    /// management", "Health-system administration". `None` falls
374    /// back to "Account security notification" in recovery emails.
375    pub app_tagline: Option<String>,
376    /// Optional support contact surfaced in recovery email footer.
377    pub support_email: Option<String>,
378    /// Optional canonical public URL — e.g. `https://library.example.com`.
379    /// Used as the reset-link base when `Host` header isn't trustworthy.
380    pub public_url: Option<String>,
381    /// `true` → renders a small, low-contrast "Powered by RustIO"
382    /// line in the admin chrome footer and at the very bottom of
383    /// framework emails. Default: `false` (framework name invisible).
384    pub show_powered_by: bool,
385    // ---- legacy fields, retained for backwards compatibility -----
386    pub site_title: String,
387    pub site_header: String,
388    pub index_title: String,
389    pub footer_copyright: String,
390    /// DNS-shape string available to project handlers; not surfaced in
391    /// any framework template.
392    pub domain: String,
393}
394
395impl Default for SiteBranding {
396    fn default() -> Self {
397        // Generic "Admin" defaults so a zero-config build doesn't
398        // leak the framework name. Real projects MUST set app_name
399        // via `Admin::app_name(...)` or a full `site_branding(...)`
400        // override.
401        Self {
402            app_name: "Admin".into(),
403            app_tagline: None,
404            support_email: None,
405            public_url: None,
406            show_powered_by: false,
407            site_title: "Admin".into(),
408            site_header: "Admin".into(),
409            index_title: "Site administration".into(),
410            footer_copyright: String::new(),
411            domain: "localhost".into(),
412        }
413    }
414}
415
416// public:
417/// Project-level override patch for the admin chrome palette.
418///
419/// `admin.css` is the single source of truth for the framework's design
420/// tokens (light defaults, dark mode, semantic surfaces, typography
421/// scale, …). `AdminTheme` is **purely a patch layer**: every field is
422/// `Option<String>` and defaults to `None`, meaning *“don’t override —
423/// let the stylesheet decide.”* Out of the box the framework emits no
424/// inline `<style>` block at all.
425///
426/// Set a field — usually via the fluent builder methods or
427/// [`Admin::accent_color`] — to inject a `--rio-*` custom-property
428/// override on every page. Overrides apply across `data-rio-theme`
429/// states (system / light / dark) by emitting a multi-state selector
430/// after `admin.css`, so they win cascade ties without `!important`.
431///
432/// Values are hex (`#rrggbb` or `rrggbb`); the leading `#` is
433/// auto-normalised at construction. Malformed input is rejected at
434/// override time rather than panicking — the admin path never breaks
435/// over a config typo.
436#[derive(Clone, Debug, Default, PartialEq, Eq)]
437pub struct AdminTheme {
438    pub accent: Option<String>,
439    pub bg: Option<String>,
440    pub surface: Option<String>,
441    pub text: Option<String>,
442    pub text_muted: Option<String>,
443    pub border: Option<String>,
444}
445
446impl AdminTheme {
447    // public:
448    /// New empty patch — no overrides emitted, `admin.css` wins.
449    pub fn new() -> Self {
450        Self::default()
451    }
452
453    // internal:
454    /// `true` when at least one field is set. Used by the renderer to
455    /// decide whether to emit the inline `<style>` block at all.
456    pub(crate) fn has_overrides(&self) -> bool {
457        self.accent.is_some()
458            || self.bg.is_some()
459            || self.surface.is_some()
460            || self.text.is_some()
461            || self.text_muted.is_some()
462            || self.border.is_some()
463    }
464
465    // public:
466    /// Override `--rio-accent`. Hex form, `#` optional.
467    pub fn accent(mut self, color: impl Into<String>) -> Self {
468        self.accent = Some(normalise_hex(color));
469        self
470    }
471
472    // public:
473    /// Override `--rio-bg` (page canvas).
474    pub fn bg(mut self, color: impl Into<String>) -> Self {
475        self.bg = Some(normalise_hex(color));
476        self
477    }
478
479    // public:
480    /// Override `--rio-surface` (cards, topbar, sidebar, table body).
481    pub fn surface(mut self, color: impl Into<String>) -> Self {
482        self.surface = Some(normalise_hex(color));
483        self
484    }
485
486    // public:
487    /// Override `--rio-text` (body text colour).
488    pub fn text(mut self, color: impl Into<String>) -> Self {
489        self.text = Some(normalise_hex(color));
490        self
491    }
492
493    // public:
494    /// Override `--rio-text-muted` (secondary text, breadcrumb links).
495    pub fn text_muted(mut self, color: impl Into<String>) -> Self {
496        self.text_muted = Some(normalise_hex(color));
497        self
498    }
499
500    // public:
501    /// Override `--rio-border` (default divider, card outline).
502    pub fn border(mut self, color: impl Into<String>) -> Self {
503        self.border = Some(normalise_hex(color));
504        self
505    }
506}
507
508// public:
509/// Builder for the admin. Register models with `.model::<M>()`, then
510/// hand it to the router via `register_admin_routes`.
511pub struct Admin {
512    pub(crate) entries: Vec<AdminEntry>,
513    pub(crate) site_branding: SiteBranding,
514    pub(crate) user_profile_ext: Option<UserProfileExtensionFn>,
515    pub(crate) theme: AdminTheme,
516    /// The outbound-mail handle. Defaults to [`LogMailer`]; projects
517    /// override via [`Admin::mailer`]. R1+ recovery flows
518    /// (`DESIGN_RECOVERY.md` §12) read this to dispatch reset emails;
519    /// `auth::recovery::issue_reset_token` (R1 commit #7) reads it
520    /// at runtime. Held as `Arc<dyn Mailer>` so cloning the field is
521    /// a single reference-count bump and the field stays trivially
522    /// Send + Sync (the trait's supertraits are `Send + Sync`).
523    pub(crate) mailer: SharedMailer,
524    /// Whether [`Admin::mailer`] has been called to replace the
525    /// default `LogMailer`. Used by the R1 commit #9 strict-mailer
526    /// boot guard to decide whether the project's deployment is
527    /// production-ready (see [`Admin::has_custom_mailer`]). Flipped
528    /// to `true` on any call to `mailer(...)`, including a call
529    /// that re-registers a `LogMailer` instance — explicit operator
530    /// override is enough; the framework does not peek inside the
531    /// trait object.
532    pub(crate) mailer_overridden: bool,
533    /// The active password policy. Defaults to
534    /// [`DefaultPasswordPolicy::new`] (`min_len = 10`); projects
535    /// override via [`Admin::password_policy`]. Read by R1's reset
536    /// consume flow (commit #7) and the corrected `do_password_change`
537    /// (commit #11) so a single source of truth governs every
538    /// password write across the framework. Held as
539    /// `Arc<dyn PasswordPolicy>` for the same reason as the mailer
540    /// above (cheap clone, Send + Sync).
541    pub(crate) password_policy: SharedPasswordPolicy,
542    /// The active recovery policy: reset-token TTL, rate-limit
543    /// shape, strict-mailer boot guard, public-site-URL derivation.
544    /// Defaults to [`DefaultRecoveryPolicy::new`]; projects override
545    /// via [`Admin::recovery_policy`]. Read by R1's recovery
546    /// handlers (commits #7–#9). Held as `Arc<dyn RecoveryPolicy>`
547    /// — same architectural pattern as the mailer and the password
548    /// policy above.
549    pub(crate) recovery_policy: SharedRecoveryPolicy,
550    /// The active MFA enforcement policy. Defaults to
551    /// [`MfaPolicy::Optional`]; projects opt into enforcement via
552    /// [`Admin::require_mfa`]. Plain `Copy` enum (no `Arc`
553    /// indirection) — the four variants encode every operator
554    /// choice: rejected (`Disabled`), opt-in (`Optional`),
555    /// universal (`Required`), or per-role (`RequiredForRoles`).
556    /// The `login_guard` consults this field after successful
557    /// password verification (R3 commit #15); this commit lands
558    /// the data, the routing follows.
559    pub(crate) mfa_policy: MfaPolicy,
560}
561
562impl Default for Admin {
563    fn default() -> Self {
564        Self::new()
565    }
566}
567
568impl Admin {
569    // public:
570    /// Constructs a new `Admin` with the framework's core entries
571    /// pre-seeded. The only core entry is `User`; project models are
572    /// added on top via [`Self::model`]. The outbound mailer
573    /// defaults to [`LogMailer`] — safe for dev / CI / testing,
574    /// **not suitable for production** (recovery emails are written
575    /// to `log::info!` instead of being sent). Projects opt into a
576    /// real mailer via [`Self::mailer`].
577    pub fn new() -> Self {
578        Self {
579            entries: vec![core_user_entry()],
580            site_branding: SiteBranding::default(),
581            user_profile_ext: None,
582            theme: AdminTheme::default(),
583            mailer: Arc::new(LogMailer),
584            mailer_overridden: false,
585            password_policy: Arc::new(DefaultPasswordPolicy::new()),
586            recovery_policy: Arc::new(DefaultRecoveryPolicy::new()),
587            mfa_policy: MfaPolicy::default(),
588        }
589    }
590
591    // public:
592    /// Replace the entire [`SiteBranding`] block. For finer-grained
593    /// adjustments, use the per-field builders below
594    /// ([`Admin::app_name`], [`Admin::app_tagline`], …).
595    pub fn site_branding(mut self, branding: SiteBranding) -> Self {
596        self.site_branding = branding;
597        self
598    }
599
600    // public:
601    /// Set the user-facing product identity. **Recommended for every
602    /// production deployment** — the framework name "RustIO" should
603    /// not appear in operational user surfaces.
604    ///
605    /// Example: `Admin::new().app_name("Library Circulation")`.
606    /// Also mirrors the value into the legacy `site_title` /
607    /// `site_header` fields so older paths that still read them stay
608    /// coherent with the new identity.
609    pub fn app_name(mut self, name: impl Into<String>) -> Self {
610        let n = name.into();
611        self.site_branding.app_name = n.clone();
612        // Mirror into the legacy fields so any old read path stays
613        // consistent with the canonical name. Projects that override
614        // `site_branding` directly bypass this mirroring.
615        self.site_branding.site_title = n.clone();
616        self.site_branding.site_header = n;
617        self
618    }
619
620    // public:
621    /// Set an optional secondary line shown under the brand
622    /// wordmark in emails + auth pages.
623    pub fn app_tagline(mut self, tagline: impl Into<String>) -> Self {
624        self.site_branding.app_tagline = Some(tagline.into());
625        self
626    }
627
628    // public:
629    /// Set the support contact surfaced in recovery emails.
630    pub fn support_email(mut self, email: impl Into<String>) -> Self {
631        self.site_branding.support_email = Some(email.into());
632        self
633    }
634
635    // public:
636    /// Set the canonical public URL — used as a base when composing
637    /// reset links if request-header derivation is unreliable.
638    pub fn public_url(mut self, url: impl Into<String>) -> Self {
639        self.site_branding.public_url = Some(url.into());
640        self
641    }
642
643    // public:
644    /// Opt in to the small "Powered by RustIO" credit in chrome
645    /// footer + email footer. Off by default; the framework name
646    /// stays invisible to end users unless this is enabled.
647    pub fn show_powered_by(mut self, show: bool) -> Self {
648        self.site_branding.show_powered_by = show;
649        self
650    }
651
652    // public:
653    /// Read-only access to the active branding.
654    pub fn branding(&self) -> &SiteBranding {
655        &self.site_branding
656    }
657
658    // public:
659    /// Set the admin chrome's accent colour. Hex form, with or without
660    /// the leading `#` (`"#1e6ba8"` and `"1e6ba8"` both work). Replaces
661    /// any prior accent override; other [`AdminTheme`] fields are
662    /// left untouched.
663    pub fn accent_color(mut self, color: impl Into<String>) -> Self {
664        self.theme.accent = Some(normalise_hex(color));
665        self
666    }
667
668    // public:
669    /// Replace the entire admin chrome palette patch in one call. See
670    /// [`AdminTheme`] for the field-by-field contract.
671    pub fn theme(mut self, theme: AdminTheme) -> Self {
672        self.theme = theme;
673        self
674    }
675
676    // public:
677    /// Read-only access to the configured accent colour, if any. `None`
678    /// means *“no override — admin.css owns it”*.
679    pub fn accent(&self) -> Option<&str> {
680        self.theme.accent.as_deref()
681    }
682
683    // public:
684    /// Read-only access to the active theme override patch.
685    pub fn active_theme(&self) -> &AdminTheme {
686        &self.theme
687    }
688
689    // public:
690    /// Replace the outbound mailer. Closes the
691    /// documented-but-unimplemented gap from 0.4.0 where the doc
692    /// comments described this method while the `Admin` struct had
693    /// no mailer field; landed in 0.5.0 alongside the R1 recovery
694    /// pipeline that consumes it (`DESIGN_RECOVERY.md` §10.3).
695    ///
696    /// Typical project wiring:
697    ///
698    /// ```ignore
699    /// use std::sync::Arc;
700    /// let admin = Admin::new()
701    ///     .mailer(Arc::new(MyProjectMailer::new(/* SES, Mailgun, … */)));
702    /// ```
703    ///
704    /// The framework imposes no transport. Anything that implements
705    /// the [`crate::email::Mailer`] trait (which is `Send + Sync`
706    /// and async-friendly) plugs in here. R1's recovery flow reads
707    /// this via [`Self::active_mailer`] and dispatches reset
708    /// emails through it.
709    pub fn mailer(mut self, mailer: SharedMailer) -> Self {
710        self.mailer = mailer;
711        self.mailer_overridden = true;
712        self
713    }
714
715    // public:
716    /// Read-only access to the registered mailer. Returns a borrow
717    /// of the `Arc` so handlers can `.clone()` it cheaply when they
718    /// need to move the handle into an async future. Always returns
719    /// a live mailer — `Admin::new()` seeds [`LogMailer`] as the
720    /// default, so this never returns `None`.
721    pub fn active_mailer(&self) -> &SharedMailer {
722        &self.mailer
723    }
724
725    // public:
726    /// Whether the project explicitly called [`Self::mailer`] to
727    /// register a mailer. Returns `false` for `Admin::new()` (the
728    /// framework's `LogMailer` default is in place); flips to `true`
729    /// on any subsequent call to `mailer(...)`, regardless of the
730    /// concrete type supplied — the framework trusts the operator's
731    /// explicit override.
732    ///
733    /// Read by the R1 strict-mailer boot guard: when
734    /// `RecoveryPolicy::strict_mailer_required() == true` and this
735    /// returns `false`, `register_admin_routes` panics at startup
736    /// rather than registering the recovery routes against a
737    /// production-unsafe default mailer.
738    pub fn has_custom_mailer(&self) -> bool {
739        self.mailer_overridden
740    }
741
742    // public:
743    /// Replace the active password policy. R1 ships with the
744    /// length-only [`DefaultPasswordPolicy`] (`min_len = 10`);
745    /// production deployments commonly override to 12+, and
746    /// regulated deployments may ship a full custom impl with breach
747    /// blocklists or organisational complexity rules
748    /// (`DESIGN_RECOVERY.md` §13).
749    ///
750    /// Typical project wiring:
751    ///
752    /// ```ignore
753    /// use std::sync::Arc;
754    /// use rustio_admin::auth::DefaultPasswordPolicy;
755    ///
756    /// let admin = Admin::new()
757    ///     .password_policy(Arc::new(DefaultPasswordPolicy::with_min_len(16)));
758    /// ```
759    pub fn password_policy(mut self, policy: SharedPasswordPolicy) -> Self {
760        self.password_policy = policy;
761        self
762    }
763
764    // public:
765    /// Read-only access to the registered password policy. Returns
766    /// a borrow of the `Arc` so handlers can `.clone()` it cheaply
767    /// when needed. Always returns a live policy — `Admin::new()`
768    /// seeds [`DefaultPasswordPolicy`] so this never returns `None`.
769    pub fn active_password_policy(&self) -> &SharedPasswordPolicy {
770        &self.password_policy
771    }
772
773    // public:
774    /// Replace the active recovery policy. R1 ships with
775    /// [`DefaultRecoveryPolicy`] (TTL 1h, request 5/15min, consume
776    /// 10/5min, strict-mailer guard off); production deployments
777    /// commonly opt into the strict guard via
778    /// `with_strict_mailer_required(true)` after registering a real
779    /// mailer (`DESIGN_RECOVERY.md` §12).
780    ///
781    /// Typical project wiring:
782    ///
783    /// ```ignore
784    /// use std::sync::Arc;
785    /// use rustio_admin::auth::DefaultRecoveryPolicy;
786    ///
787    /// let admin = Admin::new()
788    ///     .recovery_policy(Arc::new(
789    ///         DefaultRecoveryPolicy::new()
790    ///             .with_strict_mailer_required(true),
791    ///     ));
792    /// ```
793    pub fn recovery_policy(mut self, policy: SharedRecoveryPolicy) -> Self {
794        self.recovery_policy = policy;
795        self
796    }
797
798    // public:
799    /// Read-only access to the registered recovery policy. Returns
800    /// a borrow of the `Arc`. Always live — `Admin::new()` seeds
801    /// [`DefaultRecoveryPolicy`] so this never returns `None`.
802    pub fn active_recovery_policy(&self) -> &SharedRecoveryPolicy {
803        &self.recovery_policy
804    }
805
806    // public:
807    /// Replace the active MFA enforcement policy. R3 ships with
808    /// [`MfaPolicy::Optional`] as the default — pre-R3 framework
809    /// behaviour, no opt-in required. Production deployments that
810    /// want MFA enforcement opt in via this builder.
811    ///
812    /// **Forward-only enforcement (D6).** Switching to
813    /// [`MfaPolicy::Required`] does NOT retroactively revoke
814    /// existing sessions; the `login_guard` redirects users
815    /// without MFA to `/admin/mfa/enroll` at the next request.
816    /// The pattern mirrors R2's `must_change_password`
817    /// interstitial (`DESIGN_R3_MFA.md` §12.3).
818    ///
819    /// **Boot guard (D1).** When `MfaPolicy != Disabled`, the
820    /// framework refuses to boot if `RUSTIO_SECRET_KEY` is
821    /// unset — the env var is required for AES-256-GCM
822    /// encryption of TOTP secrets at rest. The boot check lands
823    /// in a later R3 commit; this builder records the policy
824    /// without the check.
825    ///
826    /// Typical project wiring:
827    ///
828    /// ```ignore
829    /// use rustio_admin::auth::{MfaPolicy, Role};
830    ///
831    /// // Universal:
832    /// let admin = Admin::new().require_mfa(MfaPolicy::Required);
833    ///
834    /// // Privileged roles only:
835    /// const PRIVILEGED: &[Role] = &[Role::Administrator, Role::Supervisor];
836    /// let admin = Admin::new()
837    ///     .require_mfa(MfaPolicy::RequiredForRoles(PRIVILEGED));
838    /// ```
839    pub fn require_mfa(mut self, policy: MfaPolicy) -> Self {
840        self.mfa_policy = policy;
841        self
842    }
843
844    // public:
845    /// Read-only access to the active MFA policy. Returns by
846    /// value — the policy is `Copy`. Always live — `Admin::new()`
847    /// seeds [`MfaPolicy::default`] (`Optional`) so this never
848    /// returns `None`.
849    pub fn active_mfa_policy(&self) -> MfaPolicy {
850        self.mfa_policy
851    }
852
853    // public:
854    pub fn model<M>(mut self) -> Self
855    where
856        M: super::ModelAdmin + crate::orm::Model,
857    {
858        let ops: Arc<dyn AdminOps> = Arc::new(super::ops::ConcreteOps::<M>::new());
859        self.entries.push(AdminEntry {
860            admin_name: M::ADMIN_NAME,
861            display_name: M::DISPLAY_NAME,
862            singular_name: M::SINGULAR_NAME,
863            table: <M as crate::orm::Model>::TABLE,
864            fields: M::FIELDS,
865            core: false,
866            list_display: M::list_display(),
867            list_filter: M::list_filter(),
868            search_fields: M::search_fields(),
869            ordering: M::ordering(),
870            list_per_page: M::list_per_page(),
871            readonly_fields: M::readonly_fields(),
872            fieldsets: M::fieldsets(),
873            bulk_actions: M::bulk_actions(),
874            ops,
875        });
876        self
877    }
878
879    // public:
880    pub fn entries(&self) -> &[AdminEntry] {
881        &self.entries
882    }
883
884    // public:
885    /// Register a project-specific extension that contributes extra
886    /// sections to the built-in user profile page. The closure is
887    /// invoked on every render of `GET /admin/users/:id` (Overview tab);
888    /// it receives the `Db` handle and the loaded
889    /// [`crate::auth::UserProfile`] (no `password_hash`) and returns a
890    /// `Vec<UserProfileSection>`. Sections render in the order returned,
891    /// immediately after the core profile show-grid.
892    ///
893    /// Zero-config baseline: don't call this method, and the extension
894    /// area stays empty. Projects that need richer layout than key-value
895    /// rows override the `{% block project_user_fields %}` template
896    /// block in `templates/admin/user_view.html` instead.
897    pub fn user_profile_extension<F, Fut>(mut self, ext: F) -> Self
898    where
899        F: Fn(Db, crate::auth::UserProfile) -> Fut + Send + Sync + 'static,
900        Fut: Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static,
901    {
902        self.user_profile_ext = Some(Arc::new(move |db, user| Box::pin(ext(db, user))));
903        self
904    }
905
906    /// Internal accessor — handlers fetch the registered extension
907    /// closure (if any) here. Used by `admin/builtin.rs` (P6.b).
908    #[allow(dead_code)]
909    pub(crate) fn user_profile_ext(&self) -> Option<&UserProfileExtensionFn> {
910        self.user_profile_ext.as_ref()
911    }
912
913    // public:
914    pub fn find(&self, admin_name: &str) -> Option<&AdminEntry> {
915        self.entries.iter().find(|e| e.admin_name == admin_name)
916    }
917
918    // public:
919    /// Register the canonical (add/change/delete/view) permissions for
920    /// every model. Call during startup after `init_tables`.
921    pub async fn seed_permissions(&self, db: &crate::orm::Db) -> crate::error::Result<()> {
922        for entry in &self.entries {
923            let singular = entry.singular_name.to_ascii_lowercase();
924            crate::auth::register_model_permissions(db, entry.admin_name, &singular).await?;
925        }
926        Ok(())
927    }
928}
929
930// -------------------------------------------------------------------------
931// Core User entry — synthetic, route-only stub
932// -------------------------------------------------------------------------
933//
934// Every project's admin index lists `Users` so operators can navigate
935// to the bespoke `/admin/users/*` pages owned by `admin::builtin`. The
936// `User` entry is built directly here rather than implementing
937// `AdminModel` on a placeholder struct: the auth subsystem already
938// owns the live `/admin/users` page with its own logic; routing
939// through generic CRUD here would spawn a duplicate page.
940
941const CORE_USER_FIELDS: &[AdminField] = &[
942    AdminField {
943        name: "id",
944        label: "id",
945        field_type: FieldType::I64,
946        editable: false,
947        relation: None,
948        choices: None,
949    },
950    AdminField {
951        name: "email",
952        label: "email",
953        field_type: FieldType::String,
954        editable: true,
955        relation: None,
956        choices: None,
957    },
958    AdminField {
959        name: "password_hash",
960        label: "password_hash",
961        field_type: FieldType::String,
962        editable: false,
963        relation: None,
964        choices: None,
965    },
966    AdminField {
967        name: "role",
968        label: "role",
969        field_type: FieldType::String,
970        editable: true,
971        relation: None,
972        choices: None,
973    },
974    AdminField {
975        name: "is_active",
976        label: "is_active",
977        field_type: FieldType::Bool,
978        editable: true,
979        relation: None,
980        choices: None,
981    },
982    AdminField {
983        name: "created_at",
984        label: "created_at",
985        field_type: FieldType::DateTime,
986        editable: false,
987        relation: None,
988        choices: None,
989    },
990];
991
992/// Normalise a user-supplied colour string to `#rrggbb` form. Accepts
993/// both `"#1e6ba8"` and `"1e6ba8"`; trims whitespace; does NOT validate
994/// that the body is hex (that's the renderer's job, where invalid
995/// values fall back to the framework default rather than panic). The
996/// `format!()` adds back exactly one leading `#`.
997pub(crate) fn normalise_hex(input: impl Into<String>) -> String {
998    let raw = input.into();
999    let trimmed = raw.trim().trim_start_matches('#');
1000    format!("#{trimmed}")
1001}
1002
1003fn core_user_entry() -> AdminEntry {
1004    AdminEntry {
1005        admin_name: "users",
1006        display_name: "Users",
1007        singular_name: "User",
1008        table: "rustio_users",
1009        fields: CORE_USER_FIELDS,
1010        core: true,
1011        list_display: &[],
1012        list_filter: &[],
1013        search_fields: &[],
1014        ordering: &["-id"],
1015        list_per_page: 50,
1016        readonly_fields: &[],
1017        fieldsets: &[],
1018        bulk_actions: &[],
1019        ops: Arc::new(CoreUserOps),
1020    }
1021}
1022
1023/// Route-only stub for the synthetic User entry. The live
1024/// `/admin/users` page is wired separately by `admin::builtin`, so
1025/// every method here returns a dedicated error rather than silently
1026/// half-working. If the generic admin ever routes to this, the error
1027/// makes the misuse obvious.
1028struct CoreUserOps;
1029
1030fn core_user_route_error() -> crate::error::Error {
1031    crate::error::Error::Internal(
1032        "the core User entry is route-only — use the dedicated /admin/users page".into(),
1033    )
1034}
1035
1036impl AdminOps for CoreUserOps {
1037    fn list<'a>(
1038        &'a self,
1039        _db: &'a Db,
1040        _opts: ListOpts,
1041    ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>> {
1042        Box::pin(async { Err(core_user_route_error()) })
1043    }
1044
1045    fn find_row<'a>(
1046        &'a self,
1047        _db: &'a Db,
1048        _id: i64,
1049    ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
1050        Box::pin(async { Err(core_user_route_error()) })
1051    }
1052
1053    fn create<'a>(&'a self, _db: &'a Db, _form: &'a FormData) -> CreateResult<'a> {
1054        Box::pin(async { Err(core_user_route_error()) })
1055    }
1056
1057    fn update<'a>(&'a self, _db: &'a Db, _id: i64, _form: &'a FormData) -> UpdateResult<'a> {
1058        Box::pin(async { Err(core_user_route_error()) })
1059    }
1060
1061    fn delete<'a>(
1062        &'a self,
1063        _db: &'a Db,
1064        _id: i64,
1065    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
1066        Box::pin(async { Err(core_user_route_error()) })
1067    }
1068
1069    fn object_label<'a>(
1070        &'a self,
1071        _db: &'a Db,
1072        _id: i64,
1073    ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>> {
1074        Box::pin(async { Err(core_user_route_error()) })
1075    }
1076
1077    fn execute_bulk_action<'a>(
1078        &'a self,
1079        _db: &'a Db,
1080        name: &'a str,
1081        _ids: &'a [i64],
1082        _ctx: &'a super::bulk::BulkActionContext<'a>,
1083    ) -> Pin<Box<dyn Future<Output = Result<super::bulk::BulkActionResult>> + Send + 'a>> {
1084        // The User entry has no project-owned bulk actions; the
1085        // framework manages user row writes through dedicated CLI /
1086        // admin routes. Surface the same "no project handler" shape
1087        // as the ModelAdmin default so a misconfigured registration
1088        // gets a clear error rather than a silent no-op.
1089        let owned = name.to_string();
1090        Box::pin(async move {
1091            Err(crate::error::Error::BadRequest(format!(
1092                "bulk action `{owned}` is not available on the framework User entry"
1093            )))
1094        })
1095    }
1096}
1097
1098// Test fixtures (PanicOps / FailingOps + AdminEntry::for_testing*) live
1099// with the legacy `admin/macro_tests.rs` etc. that haven't been ported
1100// yet. Re-add them here when the first in-tree test needs them.
1101
1102#[cfg(test)]
1103mod tests {
1104    use super::*;
1105    use crate::auth::{PasswordPolicy, PasswordPolicyError};
1106
1107    #[test]
1108    fn admin_new_installs_default_password_policy() {
1109        let admin = Admin::new();
1110        // Default floor is 10 (per DESIGN_RECOVERY.md §13.2).
1111        assert_eq!(admin.active_password_policy().min_length(), 10);
1112        // Sanity: a 9-char password is rejected, a 10-char is accepted.
1113        assert!(admin
1114            .active_password_policy()
1115            .validate("nine_char")
1116            .is_err());
1117        assert!(admin
1118            .active_password_policy()
1119            .validate("ten_chars_")
1120            .is_ok());
1121    }
1122
1123    #[test]
1124    fn admin_password_policy_overrides_default() {
1125        struct StubPolicy;
1126        impl PasswordPolicy for StubPolicy {
1127            fn validate(&self, _candidate: &str) -> std::result::Result<(), PasswordPolicyError> {
1128                Err(PasswordPolicyError::Custom("stub rejected".into()))
1129            }
1130            fn min_length(&self) -> usize {
1131                99
1132            }
1133        }
1134
1135        let admin = Admin::new().password_policy(Arc::new(StubPolicy));
1136        assert_eq!(admin.active_password_policy().min_length(), 99);
1137        let err = admin
1138            .active_password_policy()
1139            .validate("anything-at-all-here")
1140            .unwrap_err();
1141        assert_eq!(err, PasswordPolicyError::Custom("stub rejected".into()));
1142    }
1143
1144    #[test]
1145    fn admin_new_installs_default_recovery_policy() {
1146        let admin = Admin::new();
1147        let p = admin.active_recovery_policy();
1148        // Locked defaults from DESIGN_RECOVERY.md §17.
1149        assert_eq!(p.reset_token_ttl(), chrono::Duration::hours(1));
1150        assert_eq!(
1151            p.request_rate_limit(),
1152            (5, std::time::Duration::from_secs(15 * 60))
1153        );
1154        assert_eq!(
1155            p.consume_rate_limit(),
1156            (10, std::time::Duration::from_secs(5 * 60))
1157        );
1158        assert!(!p.strict_mailer_required());
1159    }
1160
1161    #[test]
1162    fn admin_new_has_no_custom_mailer() {
1163        let admin = Admin::new();
1164        assert!(!admin.has_custom_mailer());
1165    }
1166
1167    #[test]
1168    fn admin_mailer_builder_flips_override_flag() {
1169        // Even when the override happens to register another LogMailer,
1170        // the explicit call is what the strict-mailer guard reads.
1171        let admin = Admin::new().mailer(Arc::new(crate::email::LogMailer));
1172        assert!(admin.has_custom_mailer());
1173    }
1174
1175    #[test]
1176    fn admin_recovery_policy_overrides_default() {
1177        use crate::auth::RecoveryPolicy;
1178
1179        struct StubRecoveryPolicy;
1180        impl RecoveryPolicy for StubRecoveryPolicy {
1181            fn reset_token_ttl(&self) -> chrono::Duration {
1182                chrono::Duration::hours(2)
1183            }
1184            fn request_rate_limit(&self) -> (u32, std::time::Duration) {
1185                (1, std::time::Duration::from_secs(60))
1186            }
1187            fn consume_rate_limit(&self) -> (u32, std::time::Duration) {
1188                (2, std::time::Duration::from_secs(120))
1189            }
1190            fn strict_mailer_required(&self) -> bool {
1191                true
1192            }
1193            // public_site_url uses the trait's provided default.
1194        }
1195
1196        let admin = Admin::new().recovery_policy(Arc::new(StubRecoveryPolicy));
1197        let p = admin.active_recovery_policy();
1198        assert_eq!(p.reset_token_ttl(), chrono::Duration::hours(2));
1199        assert_eq!(
1200            p.request_rate_limit(),
1201            (1, std::time::Duration::from_secs(60))
1202        );
1203        assert_eq!(
1204            p.consume_rate_limit(),
1205            (2, std::time::Duration::from_secs(120))
1206        );
1207        assert!(p.strict_mailer_required());
1208    }
1209}