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::collections::HashSet;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::Arc;
13
14use crate::auth::{
15    DefaultPasswordPolicy, DefaultRecoveryPolicy, MfaPolicy, SharedPasswordPolicy,
16    SharedRecoveryPolicy,
17};
18use crate::email::{LogMailer, SharedMailer};
19use crate::error::Result;
20use crate::http::FormData;
21use crate::orm::{Db, Value};
22
23pub(crate) type CreateResult<'a> =
24    Pin<Box<dyn Future<Output = Result<std::result::Result<i64, Vec<String>>>> + Send + 'a>>;
25
26pub(crate) type UpdateResult<'a> =
27    Pin<Box<dyn Future<Output = Result<std::result::Result<(), Vec<String>>>> + Send + 'a>>;
28
29// ---------------------------------------------------------------------------
30// User profile extension API
31// ---------------------------------------------------------------------------
32
33// public:
34/// One labeled section rendered in the project-extension area of the
35/// built-in user profile page (admin/user_view.html — `{% block
36/// project_user_fields %}`). A project's extension closure returns
37/// `Vec<UserProfileSection>` so it can contribute multiple disjoint
38/// areas in a single registration.
39#[derive(Debug, Clone, serde::Serialize)]
40pub struct UserProfileSection {
41    pub label: String,
42    pub rows: Vec<UserProfileRow>,
43}
44
45// public:
46/// One key-value row inside a [`UserProfileSection`]. Both fields are
47/// `String` so projects can format whatever shape they need. Rendered
48/// escaped — pass plain text; for arbitrary HTML, projects override
49/// the template block instead.
50#[derive(Debug, Clone, serde::Serialize)]
51pub struct UserProfileRow {
52    pub label: String,
53    pub value: String,
54}
55
56/// The boxed-closure shape stored on `Admin`. `pub(crate)` because
57/// projects use the generic [`Admin::user_profile_extension`] builder
58/// method and never have to name this directly.
59pub(crate) type UserProfileExtensionFn =
60    Arc<dyn Fn(Db, crate::auth::UserProfile) -> UserProfileExtensionFuture + Send + Sync + 'static>;
61
62pub(crate) type UserProfileExtensionFuture =
63    Pin<Box<dyn Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static>>;
64
65// public:
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67#[non_exhaustive]
68pub enum FieldType {
69    I32,
70    I64,
71    F64,
72    Decimal,
73    Bool,
74    String,
75    Email,
76    Phone,
77    DateTime,
78    Date,
79    Time,
80    Uuid,
81    OptionalI64,
82    OptionalString,
83    OptionalDateTime,
84    /// Column persists a relative file path produced by the
85    /// framework's multipart-form path. Renders as
86    /// `<input type="file">`; on POST the framework writes the
87    /// uploaded bytes to `<Admin::uploads_dir>/<rel_path>` and
88    /// injects the resulting path string back into the form so
89    /// `from_form` sees a normal `String` column.
90    FilePath,
91    /// Nullable variant of [`Self::FilePath`].
92    OptionalFilePath,
93}
94
95impl FieldType {
96    // public:
97    pub fn widget(&self) -> &'static str {
98        match self {
99            FieldType::Bool => "checkbox",
100            FieldType::DateTime | FieldType::OptionalDateTime => "datetime",
101            FieldType::Date => "date",
102            FieldType::Time => "time",
103            FieldType::Email => "email",
104            FieldType::Phone => "tel",
105            FieldType::I32
106            | FieldType::I64
107            | FieldType::OptionalI64
108            | FieldType::F64
109            | FieldType::Decimal => "number",
110            FieldType::FilePath | FieldType::OptionalFilePath => "file",
111            FieldType::Uuid | FieldType::String | FieldType::OptionalString => "text",
112        }
113    }
114
115    // public:
116    pub fn nullable(&self) -> bool {
117        matches!(
118            self,
119            FieldType::OptionalI64
120                | FieldType::OptionalString
121                | FieldType::OptionalDateTime
122                | FieldType::OptionalFilePath
123        )
124    }
125}
126
127// public:
128#[derive(Debug, Clone)]
129pub struct AdminField {
130    pub name: &'static str,
131    pub label: &'static str,
132    pub field_type: FieldType,
133    pub editable: bool,
134    pub relation: Option<AdminRelation>,
135    /// Closed list of allowed string values for this field. When
136    /// `Some`, the form layer renders a `<select>` with one option per
137    /// entry. The values double as labels (raw, not humanised) per
138    /// the "no invented content" rule.
139    pub choices: Option<&'static [&'static str]>,
140}
141
142// public:
143#[derive(Debug, Clone)]
144pub struct AdminRelation {
145    pub target_model: &'static str,
146    pub display_field: Option<&'static str>,
147    /// `true` for many-to-many relations (form renders
148    /// `<select multiple>`), `false` for the default belongs-to
149    /// (single `<select>`). Macro emits `false`; consumers that want
150    /// M2M behaviour must hand-set this until the macro learns a
151    /// `#[rustio(many_to_many)]` attribute.
152    pub multi: bool,
153}
154
155// public:
156/// What the `#[derive(RustioAdmin)]` macro produces for each struct.
157pub trait AdminModel: Send + Sync + 'static {
158    const ADMIN_NAME: &'static str;
159    const DISPLAY_NAME: &'static str;
160    const SINGULAR_NAME: &'static str;
161    const FIELDS: &'static [AdminField];
162
163    /// Render one row for the list page (column → display string).
164    fn display_values(&self) -> Vec<(String, String)>;
165
166    /// Populate a new instance from an HTTP form. Returns a list of
167    /// validation errors if anything was wrong.
168    fn from_form(form: &FormData) -> std::result::Result<Self, Vec<String>>
169    where
170        Self: Sized;
171
172    /// A stable label for one instance (used on the delete confirm page).
173    fn object_label(&self) -> String;
174
175    fn id(&self) -> i64;
176
177    fn values_to_update(&self) -> Vec<(&'static str, Value)>;
178}
179
180// public:
181/// Runtime metadata about one admin-registered model. Captures both
182/// the [`AdminModel`] static surface and the [`super::ModelAdmin`]
183/// customisation values at registration time, so handlers read every
184/// per-model knob from this struct instead of re-resolving traits.
185pub struct AdminEntry {
186    pub admin_name: &'static str,
187    pub display_name: &'static str,
188    pub singular_name: &'static str,
189    /// SQL table name. For user-registered models this is `<M as Model>::TABLE`;
190    /// for the synthetic core User entry it's `"rustio_users"`.
191    pub table: &'static str,
192    pub fields: &'static [AdminField],
193    /// `true` only for framework-owned entries (currently just `User`).
194    pub core: bool,
195    /// `ModelAdmin::list_display()`. Empty → use every column on
196    /// `fields`; non-empty → use exactly the listed names in order.
197    pub list_display: &'static [&'static str],
198    /// `ModelAdmin::list_filter()`. Empty by default.
199    pub list_filter: &'static [&'static str],
200    /// `ModelAdmin::search_fields()`. Empty by default.
201    pub search_fields: &'static [&'static str],
202    /// `ModelAdmin::search_index_column()`. When `Some`, the
203    /// list-page search uses Postgres FTS against this tsvector
204    /// column instead of the default `ILIKE` OR-loop across
205    /// `search_fields`.
206    pub search_index_column: Option<&'static str>,
207    /// `ModelAdmin::ordering()`. Strings parsed via
208    /// [`super::modeladmin::parse_order_spec`].
209    pub ordering: &'static [&'static str],
210    /// `ModelAdmin::list_per_page()`. Default 50.
211    pub list_per_page: usize,
212    /// `ModelAdmin::readonly_fields()`. Empty by default.
213    pub readonly_fields: &'static [&'static str],
214    /// `ModelAdmin::fieldsets()`. Empty → fall back to the
215    /// framework's name-heuristic grouping.
216    pub fieldsets: &'static [super::modeladmin::Fieldset],
217    /// `ModelAdmin::bulk_actions()`. Empty by default — the bulk bar
218    /// only renders the framework's built-in Delete.
219    pub bulk_actions: &'static [super::modeladmin::BulkAction],
220    /// `ModelAdmin::inlines()`. Empty by default — no related-
221    /// children section renders below the edit form. Project
222    /// authors opt in per parent model.
223    pub inlines: &'static [super::modeladmin::Inline],
224    pub(crate) ops: Arc<dyn AdminOps>,
225}
226
227// public:
228/// Per-request options for [`AdminOps::list`]. Empty / `None` fields
229/// mean "framework default": no ordering override falls back to
230/// `id DESC` inside the runtime, no filters skips the WHERE clause,
231/// no limit fetches every row.
232#[derive(Debug, Clone, Default)]
233pub struct ListOpts {
234    /// Validated `(column, dir)` pairs to apply as `ORDER BY`. The
235    /// column name is bound to the model's `M::COLUMNS` set inside
236    /// the runtime, so callers can pass user-supplied names without
237    /// SQL-injection risk.
238    pub ordering: Vec<(String, super::modeladmin::SortDir)>,
239    /// `(column, value)` pairs applied as `WHERE col::text = $N`.
240    /// Cast to text so the comparison matches the same string-shape
241    /// semantics the in-memory pre-P10 filter used for bool / int /
242    /// timestamp columns.
243    pub filters: Vec<(String, String)>,
244    /// Date-range filters surfaced from
245    /// [`super::filters::FilterKind::DateRange`]: each tuple is
246    /// `(column, gte, lte)` with `YYYY-MM-DD` strings. Either bound
247    /// may be `None` (open-ended). The runtime renders
248    /// `WHERE col::date >= $N::date` and / or
249    /// `WHERE col::date <= $N::date`. Column names are validated
250    /// against `M::COLUMNS`; the date strings are passed through
251    /// to Postgres which rejects malformed inputs.
252    pub date_ranges: Vec<(String, Option<String>, Option<String>)>,
253    /// Multi-select filters surfaced from
254    /// [`super::filters::FilterKind::MultiSelect`]: each tuple is
255    /// `(column, values)` and renders as
256    /// `WHERE col::text IN ($N, $N+1, …)`. An empty `values` list is
257    /// silently skipped — "nothing selected" should not collapse the
258    /// query to an empty result. Column names are validated against
259    /// `M::COLUMNS`; the caller is responsible for restricting
260    /// values to the field's declared `choices`.
261    pub multi_filters: Vec<(String, Vec<String>)>,
262    /// Free-text search: `(term, columns)`. The runtime emits
263    /// `WHERE (col1::text ILIKE $N OR col2::text ILIKE $N OR …)`
264    /// with `$N = '%term%'`. An empty `term` or empty `columns`
265    /// leaves the WHERE alone.
266    pub search: Option<(String, Vec<String>)>,
267    /// When `Some(col)`, the search WHERE clause uses Postgres
268    /// FTS against this tsvector column
269    /// (`<col> @@ websearch_to_tsquery('english', $N)`) instead
270    /// of the ILIKE OR-loop above. `search` must still carry the
271    /// raw term; the columns slice is ignored on this path.
272    /// Sourced from `AdminEntry::search_index_column`.
273    pub search_index_column: Option<&'static str>,
274    /// `LIMIT $N` for the data query. The COUNT(*) query never
275    /// applies it. `None` → no limit.
276    pub limit: Option<i64>,
277    /// `OFFSET $N` for the data query. `None` or `Some(0)` → no offset.
278    pub offset: Option<i64>,
279}
280
281// public:
282/// Result of [`AdminOps::list`]: the requested page plus the total
283/// row count under the same WHERE clause (so handlers can render
284/// pagination footers without a separate query).
285#[derive(Debug, Default)]
286pub struct ListPage {
287    pub rows: Vec<ListRow>,
288    pub total: i64,
289}
290
291/// Type-erased CRUD operations. The `Admin::model::<M>()` call captures
292/// a concrete `M: AdminModel + Model` and hides it behind this trait so
293/// the router can treat every model uniformly. The single live impl is
294/// [`super::ops::ConcreteOps<M>`].
295pub(crate) trait AdminOps: Send + Sync {
296    fn list<'a>(
297        &'a self,
298        db: &'a Db,
299        opts: ListOpts,
300    ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>>;
301
302    fn find_row<'a>(
303        &'a self,
304        db: &'a Db,
305        id: i64,
306    ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>>;
307
308    fn create<'a>(&'a self, db: &'a Db, form: &'a FormData) -> CreateResult<'a>;
309
310    fn update<'a>(&'a self, db: &'a Db, id: i64, form: &'a FormData) -> UpdateResult<'a>;
311
312    fn delete<'a>(
313        &'a self,
314        db: &'a Db,
315        id: i64,
316    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
317
318    fn object_label<'a>(
319        &'a self,
320        db: &'a Db,
321        id: i64,
322    ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>>;
323
324    /// Run a project-defined bulk action against the supplied row
325    /// ids. Called once per submission with the full id list, so the
326    /// implementation can choose between a single bulk SQL update or
327    /// a per-row loop.
328    ///
329    /// The real public dispatcher is
330    /// [`super::ModelAdmin::execute_bulk_action`];
331    /// [`super::ops::ConcreteOps<M>`] forwards into the model's
332    /// override from this trait method. The
333    /// [`super::types::CoreUserOps`] entry has no project surface
334    /// (the User row is framework-owned), so it returns the
335    /// "no project handler" error verbatim.
336    ///
337    /// Note: the framework's built-in `delete` action is **not**
338    /// dispatched through here. It runs through the cascade-aware
339    /// `/bulk_delete` route which calls `delete()` per row. Override
340    /// `delete` instead if you need custom delete semantics.
341    fn execute_bulk_action<'a>(
342        &'a self,
343        db: &'a Db,
344        name: &'a str,
345        ids: &'a [i64],
346        ctx: &'a super::bulk::BulkActionContext<'a>,
347    ) -> Pin<Box<dyn Future<Output = Result<super::bulk::BulkActionResult>> + Send + 'a>>;
348}
349
350// public:
351/// A row as shown on the list page.
352#[derive(Debug)]
353pub struct ListRow {
354    pub id: i64,
355    pub cells: Vec<String>,
356    /// Optional link target per cell, parallel to `cells`. When
357    /// `Some`, the renderer wraps that cell's content in an
358    /// `<a href="/admin/{admin_name}/{id}/edit">…</a>` so foreign-key
359    /// columns become click-throughs to the related row. Populated by
360    /// the post-list hydration pass in `handlers::hydrate_fk_cells`;
361    /// `ConcreteOps::list` always emits a parallel vector of `None` of
362    /// matching length so callers that skip hydration still satisfy
363    /// the parallel-vector invariant.
364    pub cell_links: Vec<Option<CellLink>>,
365}
366
367// public:
368/// One resolved foreign-key cell. The renderer turns this into
369/// `<a href="/admin/{admin_name}/{id}/edit">…</a>` around the cell's
370/// display label.
371#[derive(Debug, Clone)]
372pub struct CellLink {
373    /// Target model's admin slug (e.g. `"categories"` for `Category`).
374    pub admin_name: String,
375    /// Target row id.
376    pub id: i64,
377}
378
379// public:
380/// The raw field values used to pre-fill the edit form.
381#[derive(Debug)]
382pub struct EditRow {
383    #[allow(dead_code)]
384    pub id: i64,
385    pub values: Vec<(String, String)>,
386}
387
388// public:
389/// Per-project admin branding — the user-facing identity layer.
390///
391/// Production deployments MUST set [`SiteBranding::app_name`] (or
392/// use the [`Admin::app_name`] builder) — the framework name
393/// `RustIO` should never appear as the visible product identity for
394/// end users. RustIO is the vendor; the *deployed application* is
395/// what users see in their inbox, on the login page, and in the
396/// password-reset flow.
397///
398/// Field roles:
399///
400///   - [`app_name`](Self::app_name) — primary user-facing product
401///     identity. Used by every framework-emitted surface that
402///     reaches end users: email subjects + headers, recovery
403///     pages, admin chrome footer, audit summaries.
404///   - [`app_tagline`](Self::app_tagline) — optional secondary
405///     line. Renders as the descriptor under the brand wordmark
406///     in recovery emails. `None` falls back to the framework's
407///     generic "Account security notification" caption.
408///   - [`support_email`](Self::support_email) — optional contact
409///     surfaced in the recovery email footer ("If you didn't
410///     request this, contact <support@…>"). `None` omits the
411///     line.
412///   - [`public_url`](Self::public_url) — canonical public URL
413///     used when composing reset links if the request's
414///     `Host` / `X-Forwarded-Host` derivation is unreliable.
415///     `None` falls back to header-derived URLs.
416///   - [`show_powered_by`](Self::show_powered_by) — opt-in
417///     "Powered by RustIO" credit in the chrome footer + email
418///     footer. Defaults to `false`; the framework name stays
419///     invisible to end users unless the project explicitly
420///     enables this.
421///
422/// Legacy fields ([`site_title`](Self::site_title) /
423/// [`site_header`](Self::site_header) / etc.) predate this
424/// architecture and are still honoured for backwards compatibility,
425/// but their defaults were renamed away from "RustIO administration"
426/// so a zero-config build no longer leaks the framework name. New
427/// code should use the `app_*` fields exclusively.
428#[derive(Clone, Debug)]
429pub struct SiteBranding {
430    /// Primary user-facing product identity. Visible in:
431    /// page chrome (topbar, footer), email subjects + headers,
432    /// audit summaries, recovery pages, login screen.
433    pub app_name: String,
434    /// Optional secondary line for use under the brand wordmark in
435    /// emails / auth surfaces. Examples: "Operational library
436    /// management", "Health-system administration". `None` falls
437    /// back to "Account security notification" in recovery emails.
438    pub app_tagline: Option<String>,
439    /// Optional support contact surfaced in recovery email footer.
440    pub support_email: Option<String>,
441    /// Optional canonical public URL — e.g. `https://library.example.com`.
442    /// Used as the reset-link base when `Host` header isn't trustworthy.
443    pub public_url: Option<String>,
444    /// `true` → renders a small, low-contrast "Powered by RustIO"
445    /// line in the admin chrome footer and at the very bottom of
446    /// framework emails. Default: `false` (framework name invisible).
447    pub show_powered_by: bool,
448    // ---- legacy fields, retained for backwards compatibility -----
449    pub site_title: String,
450    pub site_header: String,
451    pub index_title: String,
452    pub footer_copyright: String,
453    /// DNS-shape string available to project handlers; not surfaced in
454    /// any framework template.
455    pub domain: String,
456}
457
458impl Default for SiteBranding {
459    fn default() -> Self {
460        // Generic "Admin" defaults so a zero-config build doesn't
461        // leak the framework name. Real projects MUST set app_name
462        // via `Admin::app_name(...)` or a full `site_branding(...)`
463        // override.
464        Self {
465            app_name: "Admin".into(),
466            app_tagline: None,
467            support_email: None,
468            public_url: None,
469            show_powered_by: false,
470            site_title: "Admin".into(),
471            site_header: "Admin".into(),
472            index_title: "Site administration".into(),
473            footer_copyright: String::new(),
474            domain: "localhost".into(),
475        }
476    }
477}
478
479// public:
480/// Project-level override patch for the admin chrome palette.
481///
482/// `admin.css` is the single source of truth for the framework's design
483/// tokens (palette, semantic surfaces, typography scale, …).
484/// `AdminTheme` is **purely a patch layer**: every field is
485/// `Option<String>` and defaults to `None`, meaning *“don’t override —
486/// let the stylesheet decide.”* Out of the box the framework emits no
487/// inline `<style>` block at all.
488///
489/// Set a field — usually via the fluent builder methods or
490/// [`Admin::accent_color`] — to inject a `--rio-*` custom-property
491/// override on every page. Overrides are emitted as a single
492/// `html { ... }` block after `admin.css`, so they win cascade ties
493/// without `!important`. The framework is light-only.
494///
495/// Values are hex (`#rrggbb` or `rrggbb`); the leading `#` is
496/// auto-normalised at construction. Malformed input is rejected at
497/// override time rather than panicking — the admin path never breaks
498/// over a config typo.
499#[derive(Clone, Debug, Default, PartialEq, Eq)]
500pub struct AdminTheme {
501    pub accent: Option<String>,
502    pub bg: Option<String>,
503    pub surface: Option<String>,
504    pub text: Option<String>,
505    pub text_muted: Option<String>,
506    pub border: Option<String>,
507}
508
509impl AdminTheme {
510    // public:
511    /// New empty patch — no overrides emitted, `admin.css` wins.
512    pub fn new() -> Self {
513        Self::default()
514    }
515
516    // internal:
517    /// `true` when at least one field is set. Used by the renderer to
518    /// decide whether to emit the inline `<style>` block at all.
519    pub(crate) fn has_overrides(&self) -> bool {
520        self.accent.is_some()
521            || self.bg.is_some()
522            || self.surface.is_some()
523            || self.text.is_some()
524            || self.text_muted.is_some()
525            || self.border.is_some()
526    }
527
528    // public:
529    /// Override `--rio-accent`. Hex form, `#` optional.
530    pub fn accent(mut self, color: impl Into<String>) -> Self {
531        self.accent = Some(normalise_hex(color));
532        self
533    }
534
535    // public:
536    /// Override `--rio-bg` (page canvas).
537    pub fn bg(mut self, color: impl Into<String>) -> Self {
538        self.bg = Some(normalise_hex(color));
539        self
540    }
541
542    // public:
543    /// Override `--rio-surface` (cards, topbar, sidebar, table body).
544    pub fn surface(mut self, color: impl Into<String>) -> Self {
545        self.surface = Some(normalise_hex(color));
546        self
547    }
548
549    // public:
550    /// Override `--rio-text` (body text colour).
551    pub fn text(mut self, color: impl Into<String>) -> Self {
552        self.text = Some(normalise_hex(color));
553        self
554    }
555
556    // public:
557    /// Override `--rio-text-muted` (secondary text, breadcrumb links).
558    pub fn text_muted(mut self, color: impl Into<String>) -> Self {
559        self.text_muted = Some(normalise_hex(color));
560        self
561    }
562
563    // public:
564    /// Override `--rio-border` (default divider, card outline).
565    pub fn border(mut self, color: impl Into<String>) -> Self {
566        self.border = Some(normalise_hex(color));
567        self
568    }
569}
570
571// public:
572/// Builder for the admin. Register models with `.model::<M>()`, then
573/// hand it to the router via `register_admin_routes`.
574pub struct Admin {
575    pub(crate) entries: Vec<AdminEntry>,
576    pub(crate) site_branding: SiteBranding,
577    pub(crate) user_profile_ext: Option<UserProfileExtensionFn>,
578    pub(crate) theme: AdminTheme,
579    /// The outbound-mail handle. Defaults to [`LogMailer`]; projects
580    /// override via [`Admin::mailer`]. R1+ recovery flows
581    /// (`DESIGN_RECOVERY.md` §12) read this to dispatch reset emails;
582    /// `auth::recovery::issue_reset_token` (R1 commit #7) reads it
583    /// at runtime. Held as `Arc<dyn Mailer>` so cloning the field is
584    /// a single reference-count bump and the field stays trivially
585    /// Send + Sync (the trait's supertraits are `Send + Sync`).
586    pub(crate) mailer: SharedMailer,
587    /// Whether [`Admin::mailer`] has been called to replace the
588    /// default `LogMailer`. Used by the R1 commit #9 strict-mailer
589    /// boot guard to decide whether the project's deployment is
590    /// production-ready (see [`Admin::has_custom_mailer`]). Flipped
591    /// to `true` on any call to `mailer(...)`, including a call
592    /// that re-registers a `LogMailer` instance — explicit operator
593    /// override is enough; the framework does not peek inside the
594    /// trait object.
595    pub(crate) mailer_overridden: bool,
596    /// The active password policy. Defaults to
597    /// [`DefaultPasswordPolicy::new`] (`min_len = 10`); projects
598    /// override via [`Admin::password_policy`]. Read by R1's reset
599    /// consume flow (commit #7) and the corrected `do_password_change`
600    /// (commit #11) so a single source of truth governs every
601    /// password write across the framework. Held as
602    /// `Arc<dyn PasswordPolicy>` for the same reason as the mailer
603    /// above (cheap clone, Send + Sync).
604    pub(crate) password_policy: SharedPasswordPolicy,
605    /// The active recovery policy: reset-token TTL, rate-limit
606    /// shape, strict-mailer boot guard, public-site-URL derivation.
607    /// Defaults to [`DefaultRecoveryPolicy::new`]; projects override
608    /// via [`Admin::recovery_policy`]. Read by R1's recovery
609    /// handlers (commits #7–#9). Held as `Arc<dyn RecoveryPolicy>`
610    /// — same architectural pattern as the mailer and the password
611    /// policy above.
612    pub(crate) recovery_policy: SharedRecoveryPolicy,
613    /// The active MFA enforcement policy. Defaults to
614    /// [`MfaPolicy::Optional`]; projects opt into enforcement via
615    /// [`Admin::require_mfa`]. Plain `Copy` enum (no `Arc`
616    /// indirection) — the four variants encode every operator
617    /// choice: rejected (`Disabled`), opt-in (`Optional`),
618    /// universal (`Required`), or per-role (`RequiredForRoles`).
619    /// The `login_guard` consults this field after successful
620    /// password verification (R3 commit #15); this commit lands
621    /// the data, the routing follows.
622    pub(crate) mfa_policy: MfaPolicy,
623    /// Storage root for uploaded files. `None` (default) disables
624    /// the file-upload code path entirely — any
625    /// `<input type="file">` widget on a model whose framework
626    /// was built without this set is treated as inert (the form
627    /// renders, but the multipart handler returns an empty
628    /// `Form::set` for that field). Projects with file-bearing
629    /// columns opt in via [`Admin::uploads_dir`]. The directory
630    /// is created lazily on first write; the framework
631    /// canonicalises it to refuse path-traversal on the serve
632    /// route.
633    pub(crate) uploads_dir: Option<std::path::PathBuf>,
634    /// `true` puts the admin into whole-admin read-only mode: every
635    /// mutating POST under `/admin/*` (project CRUD, bulk actions,
636    /// admin-driven user lifecycle) returns 403 with a flash banner;
637    /// auth-flow POSTs (login, logout, mfa verify, password recovery,
638    /// own-session management) are explicitly allowlisted so the
639    /// operator can still get in and out. Useful for incident
640    /// response (lock the admin while investigating) and demo
641    /// environments where you want viewers but not editors. Off by
642    /// default. Project authors opt in via [`Admin::read_only`].
643    pub(crate) read_only: bool,
644    /// Per-model read-only set: when an admin slug is present here,
645    /// the `read_only_guard` middleware returns 403 on mutating
646    /// requests targeting `/admin/<slug>/...` while leaving the rest
647    /// of the admin live. Off by default — set via
648    /// [`Admin::read_only_model`]. Coexists with whole-admin
649    /// [`Self::read_only`]: a model frozen here stays frozen even
650    /// when the admin is otherwise writable.
651    pub(crate) read_only_models: HashSet<String>,
652}
653
654impl Default for Admin {
655    fn default() -> Self {
656        Self::new()
657    }
658}
659
660impl Admin {
661    // public:
662    /// Constructs a new `Admin` with the framework's core entries
663    /// pre-seeded. The only core entry is `User`; project models are
664    /// added on top via [`Self::model`]. The outbound mailer
665    /// defaults to [`LogMailer`] — safe for dev / CI / testing,
666    /// **not suitable for production** (recovery emails are written
667    /// to `log::info!` instead of being sent). Projects opt into a
668    /// real mailer via [`Self::mailer`].
669    pub fn new() -> Self {
670        Self {
671            entries: vec![core_user_entry()],
672            site_branding: SiteBranding::default(),
673            user_profile_ext: None,
674            theme: AdminTheme::default(),
675            mailer: Arc::new(LogMailer),
676            mailer_overridden: false,
677            password_policy: Arc::new(DefaultPasswordPolicy::new()),
678            recovery_policy: Arc::new(DefaultRecoveryPolicy::new()),
679            mfa_policy: MfaPolicy::default(),
680            uploads_dir: None,
681            read_only: false,
682            read_only_models: HashSet::new(),
683        }
684    }
685
686    // public:
687    /// Replace the entire [`SiteBranding`] block. For finer-grained
688    /// adjustments, use the per-field builders below
689    /// ([`Admin::app_name`], [`Admin::app_tagline`], …).
690    pub fn site_branding(mut self, branding: SiteBranding) -> Self {
691        self.site_branding = branding;
692        self
693    }
694
695    // public:
696    /// Set the user-facing product identity. **Recommended for every
697    /// production deployment** — the framework name "RustIO" should
698    /// not appear in operational user surfaces.
699    ///
700    /// Example: `Admin::new().app_name("Library Circulation")`.
701    /// Also mirrors the value into the legacy `site_title` /
702    /// `site_header` fields so older paths that still read them stay
703    /// coherent with the new identity.
704    pub fn app_name(mut self, name: impl Into<String>) -> Self {
705        let n = name.into();
706        self.site_branding.app_name = n.clone();
707        // Mirror into the legacy fields so any old read path stays
708        // consistent with the canonical name. Projects that override
709        // `site_branding` directly bypass this mirroring.
710        self.site_branding.site_title = n.clone();
711        self.site_branding.site_header = n;
712        self
713    }
714
715    // public:
716    /// Set an optional secondary line shown under the brand
717    /// wordmark in emails + auth pages.
718    pub fn app_tagline(mut self, tagline: impl Into<String>) -> Self {
719        self.site_branding.app_tagline = Some(tagline.into());
720        self
721    }
722
723    // public:
724    /// Set the support contact surfaced in recovery emails.
725    pub fn support_email(mut self, email: impl Into<String>) -> Self {
726        self.site_branding.support_email = Some(email.into());
727        self
728    }
729
730    // public:
731    /// Set the canonical public URL — used as a base when composing
732    /// reset links if request-header derivation is unreliable.
733    pub fn public_url(mut self, url: impl Into<String>) -> Self {
734        self.site_branding.public_url = Some(url.into());
735        self
736    }
737
738    // public:
739    /// Opt in to the small "Powered by RustIO" credit in chrome
740    /// footer + email footer. Off by default; the framework name
741    /// stays invisible to end users unless this is enabled.
742    pub fn show_powered_by(mut self, show: bool) -> Self {
743        self.site_branding.show_powered_by = show;
744        self
745    }
746
747    // public:
748    /// Whole-admin read-only toggle. When `true` every mutating
749    /// `POST` / `PUT` / `DELETE` under `/admin/*` is rejected with
750    /// 403 by the `read_only_guard` middleware; auth-flow routes
751    /// (login, logout, MFA verify, password recovery, own-session
752    /// management, own MFA management) are explicitly allowlisted
753    /// so operators can still sign in / out and complete forced
754    /// rotations. Templates that read [`Self::is_read_only`]
755    /// surface a banner and hide top-level "Add" affordances; per-
756    /// row Edit / Delete buttons still render in v1 (clicking
757    /// through hits the middleware), documented as a known
758    /// scoping trade-off.
759    pub fn read_only(mut self, on: bool) -> Self {
760        self.read_only = on;
761        self
762    }
763
764    // public:
765    /// `true` when the admin was constructed with [`Self::read_only`].
766    /// Consumed by the chrome (banner) and the list/dashboard
767    /// templates (suppress "Add" buttons).
768    pub fn is_read_only(&self) -> bool {
769        self.read_only
770    }
771
772    // public:
773    /// Freeze one model. Mutating requests under `/admin/<admin_name>/...`
774    /// return 403; the rest of the admin stays writable. Useful for
775    /// archive tables, regulatory holds, or per-model incident
776    /// response without flipping the whole admin to read-only.
777    ///
778    /// `admin_name` is the model's URL slug (the same value the
779    /// router matches `:admin_name` against — pluralised, e.g.
780    /// `"posts"`, `"users"`). Wrong slugs silently no-op; the
781    /// middleware checks set membership, so a typo simply doesn't
782    /// freeze anything.
783    pub fn read_only_model(mut self, admin_name: impl Into<String>) -> Self {
784        self.read_only_models.insert(admin_name.into());
785        self
786    }
787
788    // public:
789    /// `true` when `admin_name` was registered via
790    /// [`Self::read_only_model`]. The `read_only_guard` middleware
791    /// consults this to gate per-model mutations.
792    pub fn is_model_read_only(&self, admin_name: &str) -> bool {
793        self.read_only_models.contains(admin_name)
794    }
795
796    // public:
797    /// Set the storage root for uploaded files. Models declaring
798    /// `#[rustio(file)]` columns persist relative paths under this
799    /// directory; the framework serves the bytes back via
800    /// `GET /admin/uploads/<rel>`. The directory is created lazily
801    /// on first write. Leaving this unset (the default) makes any
802    /// file-input field inert at submit — the form renders, but
803    /// the multipart parse path skips file writes.
804    ///
805    /// **Path safety contract.** The framework canonicalises the
806    /// configured root once at builder time and refuses any
807    /// serve-route lookup whose resolved path lands outside the
808    /// canonical root; rejected lookups return 404 (no
809    /// information leak about whether the path could exist).
810    pub fn uploads_dir(mut self, dir: impl Into<std::path::PathBuf>) -> Self {
811        self.uploads_dir = Some(dir.into());
812        self
813    }
814
815    // public:
816    /// Read-only access to the configured uploads root, if any.
817    pub fn uploads_dir_path(&self) -> Option<&std::path::Path> {
818        self.uploads_dir.as_deref()
819    }
820
821    // public:
822    /// Read-only access to the active branding.
823    pub fn branding(&self) -> &SiteBranding {
824        &self.site_branding
825    }
826
827    // public:
828    /// Set the admin chrome's accent colour. Hex form, with or without
829    /// the leading `#` (`"#1e6ba8"` and `"1e6ba8"` both work). Replaces
830    /// any prior accent override; other [`AdminTheme`] fields are
831    /// left untouched.
832    pub fn accent_color(mut self, color: impl Into<String>) -> Self {
833        self.theme.accent = Some(normalise_hex(color));
834        self
835    }
836
837    // public:
838    /// Replace the entire admin chrome palette patch in one call. See
839    /// [`AdminTheme`] for the field-by-field contract.
840    pub fn theme(mut self, theme: AdminTheme) -> Self {
841        self.theme = theme;
842        self
843    }
844
845    // public:
846    /// Read-only access to the configured accent colour, if any. `None`
847    /// means *“no override — admin.css owns it”*.
848    pub fn accent(&self) -> Option<&str> {
849        self.theme.accent.as_deref()
850    }
851
852    // public:
853    /// Read-only access to the active theme override patch.
854    pub fn active_theme(&self) -> &AdminTheme {
855        &self.theme
856    }
857
858    // public:
859    /// Replace the outbound mailer. Closes the
860    /// documented-but-unimplemented gap from 0.4.0 where the doc
861    /// comments described this method while the `Admin` struct had
862    /// no mailer field; landed in 0.5.0 alongside the R1 recovery
863    /// pipeline that consumes it (`DESIGN_RECOVERY.md` §10.3).
864    ///
865    /// Typical project wiring:
866    ///
867    /// ```ignore
868    /// use std::sync::Arc;
869    /// let admin = Admin::new()
870    ///     .mailer(Arc::new(MyProjectMailer::new(/* SES, Mailgun, … */)));
871    /// ```
872    ///
873    /// The framework imposes no transport. Anything that implements
874    /// the [`crate::email::Mailer`] trait (which is `Send + Sync`
875    /// and async-friendly) plugs in here. R1's recovery flow reads
876    /// this via [`Self::active_mailer`] and dispatches reset
877    /// emails through it.
878    pub fn mailer(mut self, mailer: SharedMailer) -> Self {
879        self.mailer = mailer;
880        self.mailer_overridden = true;
881        self
882    }
883
884    // public:
885    /// Read-only access to the registered mailer. Returns a borrow
886    /// of the `Arc` so handlers can `.clone()` it cheaply when they
887    /// need to move the handle into an async future. Always returns
888    /// a live mailer — `Admin::new()` seeds [`LogMailer`] as the
889    /// default, so this never returns `None`.
890    pub fn active_mailer(&self) -> &SharedMailer {
891        &self.mailer
892    }
893
894    // public:
895    /// Whether the project explicitly called [`Self::mailer`] to
896    /// register a mailer. Returns `false` for `Admin::new()` (the
897    /// framework's `LogMailer` default is in place); flips to `true`
898    /// on any subsequent call to `mailer(...)`, regardless of the
899    /// concrete type supplied — the framework trusts the operator's
900    /// explicit override.
901    ///
902    /// Read by the R1 strict-mailer boot guard: when
903    /// `RecoveryPolicy::strict_mailer_required() == true` and this
904    /// returns `false`, `register_admin_routes` panics at startup
905    /// rather than registering the recovery routes against a
906    /// production-unsafe default mailer.
907    pub fn has_custom_mailer(&self) -> bool {
908        self.mailer_overridden
909    }
910
911    // public:
912    /// Replace the active password policy. R1 ships with the
913    /// length-only [`DefaultPasswordPolicy`] (`min_len = 10`);
914    /// production deployments commonly override to 12+, and
915    /// regulated deployments may ship a full custom impl with breach
916    /// blocklists or organisational complexity rules
917    /// (`DESIGN_RECOVERY.md` §13).
918    ///
919    /// Typical project wiring:
920    ///
921    /// ```ignore
922    /// use std::sync::Arc;
923    /// use rustio_admin::auth::DefaultPasswordPolicy;
924    ///
925    /// let admin = Admin::new()
926    ///     .password_policy(Arc::new(DefaultPasswordPolicy::with_min_len(16)));
927    /// ```
928    pub fn password_policy(mut self, policy: SharedPasswordPolicy) -> Self {
929        self.password_policy = policy;
930        self
931    }
932
933    // public:
934    /// Read-only access to the registered password policy. Returns
935    /// a borrow of the `Arc` so handlers can `.clone()` it cheaply
936    /// when needed. Always returns a live policy — `Admin::new()`
937    /// seeds [`DefaultPasswordPolicy`] so this never returns `None`.
938    pub fn active_password_policy(&self) -> &SharedPasswordPolicy {
939        &self.password_policy
940    }
941
942    // public:
943    /// Replace the active recovery policy. R1 ships with
944    /// [`DefaultRecoveryPolicy`] (TTL 1h, request 5/15min, consume
945    /// 10/5min, strict-mailer guard off); production deployments
946    /// commonly opt into the strict guard via
947    /// `with_strict_mailer_required(true)` after registering a real
948    /// mailer (`DESIGN_RECOVERY.md` §12).
949    ///
950    /// Typical project wiring:
951    ///
952    /// ```ignore
953    /// use std::sync::Arc;
954    /// use rustio_admin::auth::DefaultRecoveryPolicy;
955    ///
956    /// let admin = Admin::new()
957    ///     .recovery_policy(Arc::new(
958    ///         DefaultRecoveryPolicy::new()
959    ///             .with_strict_mailer_required(true),
960    ///     ));
961    /// ```
962    pub fn recovery_policy(mut self, policy: SharedRecoveryPolicy) -> Self {
963        self.recovery_policy = policy;
964        self
965    }
966
967    // public:
968    /// Read-only access to the registered recovery policy. Returns
969    /// a borrow of the `Arc`. Always live — `Admin::new()` seeds
970    /// [`DefaultRecoveryPolicy`] so this never returns `None`.
971    pub fn active_recovery_policy(&self) -> &SharedRecoveryPolicy {
972        &self.recovery_policy
973    }
974
975    // public:
976    /// Replace the active MFA enforcement policy. R3 ships with
977    /// [`MfaPolicy::Optional`] as the default — pre-R3 framework
978    /// behaviour, no opt-in required. Production deployments that
979    /// want MFA enforcement opt in via this builder.
980    ///
981    /// **Forward-only enforcement (D6).** Switching to
982    /// [`MfaPolicy::Required`] does NOT retroactively revoke
983    /// existing sessions; the `login_guard` redirects users
984    /// without MFA to `/admin/mfa/enroll` at the next request.
985    /// The pattern mirrors R2's `must_change_password`
986    /// interstitial (`DESIGN_R3_MFA.md` §12.3).
987    ///
988    /// **Boot guard (D1).** When `MfaPolicy != Disabled`, the
989    /// framework refuses to boot if `RUSTIO_SECRET_KEY` is
990    /// unset — the env var is required for AES-256-GCM
991    /// encryption of TOTP secrets at rest. The boot check lands
992    /// in a later R3 commit; this builder records the policy
993    /// without the check.
994    ///
995    /// Typical project wiring:
996    ///
997    /// ```ignore
998    /// use rustio_admin::auth::{MfaPolicy, Role};
999    ///
1000    /// // Universal:
1001    /// let admin = Admin::new().require_mfa(MfaPolicy::Required);
1002    ///
1003    /// // Privileged roles only:
1004    /// const PRIVILEGED: &[Role] = &[Role::Administrator, Role::Supervisor];
1005    /// let admin = Admin::new()
1006    ///     .require_mfa(MfaPolicy::RequiredForRoles(PRIVILEGED));
1007    /// ```
1008    pub fn require_mfa(mut self, policy: MfaPolicy) -> Self {
1009        self.mfa_policy = policy;
1010        self
1011    }
1012
1013    // public:
1014    /// Read-only access to the active MFA policy. Returns by
1015    /// value — the policy is `Copy`. Always live — `Admin::new()`
1016    /// seeds [`MfaPolicy::default`] (`Optional`) so this never
1017    /// returns `None`.
1018    pub fn active_mfa_policy(&self) -> MfaPolicy {
1019        self.mfa_policy
1020    }
1021
1022    // public:
1023    pub fn model<M>(mut self) -> Self
1024    where
1025        M: super::ModelAdmin + crate::orm::Model,
1026    {
1027        // Loud-fail the FTS footgun: `search_index_column()` is only used
1028        // by the list query when the column is also in `Model::COLUMNS`
1029        // (injection-safety, see `ops::ConcreteOps::list`). If it isn't,
1030        // search silently degrades to a full `ILIKE` scan — so warn at
1031        // registration rather than leave the operator guessing.
1032        if let Some(col) = M::search_index_column() {
1033            if !<M as crate::orm::Model>::COLUMNS.contains(&col) {
1034                log::warn!(
1035                    "admin model `{}`: search_index_column() = {col:?} is not in Model::COLUMNS, \
1036                     so full-text search is disabled and the list falls back to ILIKE. \
1037                     Add {col:?} to COLUMNS to enable FTS.",
1038                    M::ADMIN_NAME
1039                );
1040            }
1041        }
1042
1043        let ops: Arc<dyn AdminOps> = Arc::new(super::ops::ConcreteOps::<M>::new());
1044        self.entries.push(AdminEntry {
1045            admin_name: M::ADMIN_NAME,
1046            display_name: M::DISPLAY_NAME,
1047            singular_name: M::SINGULAR_NAME,
1048            table: <M as crate::orm::Model>::TABLE,
1049            fields: M::FIELDS,
1050            core: false,
1051            list_display: M::list_display(),
1052            list_filter: M::list_filter(),
1053            search_fields: M::search_fields(),
1054            search_index_column: M::search_index_column(),
1055            ordering: M::ordering(),
1056            list_per_page: M::list_per_page(),
1057            readonly_fields: M::readonly_fields(),
1058            fieldsets: M::fieldsets(),
1059            bulk_actions: M::bulk_actions(),
1060            inlines: M::inlines(),
1061            ops,
1062        });
1063        self
1064    }
1065
1066    // public:
1067    pub fn entries(&self) -> &[AdminEntry] {
1068        &self.entries
1069    }
1070
1071    // public:
1072    /// Register a project-specific extension that contributes extra
1073    /// sections to the built-in user profile page. The closure is
1074    /// invoked on every render of `GET /admin/users/:id` (Overview tab);
1075    /// it receives the `Db` handle and the loaded
1076    /// [`crate::auth::UserProfile`] (no `password_hash`) and returns a
1077    /// `Vec<UserProfileSection>`. Sections render in the order returned,
1078    /// immediately after the core profile show-grid.
1079    ///
1080    /// Zero-config baseline: don't call this method, and the extension
1081    /// area stays empty. Projects that need richer layout than key-value
1082    /// rows override the `{% block project_user_fields %}` template
1083    /// block in `templates/admin/user_view.html` instead.
1084    pub fn user_profile_extension<F, Fut>(mut self, ext: F) -> Self
1085    where
1086        F: Fn(Db, crate::auth::UserProfile) -> Fut + Send + Sync + 'static,
1087        Fut: Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static,
1088    {
1089        self.user_profile_ext = Some(Arc::new(move |db, user| Box::pin(ext(db, user))));
1090        self
1091    }
1092
1093    /// Internal accessor — handlers fetch the registered extension
1094    /// closure (if any) here. Used by `admin/builtin.rs` (P6.b).
1095    #[allow(dead_code)]
1096    pub(crate) fn user_profile_ext(&self) -> Option<&UserProfileExtensionFn> {
1097        self.user_profile_ext.as_ref()
1098    }
1099
1100    // public:
1101    pub fn find(&self, admin_name: &str) -> Option<&AdminEntry> {
1102        self.entries.iter().find(|e| e.admin_name == admin_name)
1103    }
1104
1105    // public:
1106    /// Register the canonical (add/change/delete/view) permissions for
1107    /// every model. Call during startup after `init_tables`.
1108    ///
1109    /// Fast-path: the per-model `INSERT`s below are idempotent but cost a
1110    /// burst of round-trips on every boot. We stamp a fingerprint of
1111    /// `(crate version + sorted model set)` into `rustio_admin_meta`; when
1112    /// it already matches we skip the loop. Adding/removing a model
1113    /// changes the fingerprint and re-seeds; a framework upgrade does too.
1114    /// To force a re-seed, `TRUNCATE rustio_admin_meta`.
1115    pub async fn seed_permissions(&self, db: &crate::orm::Db) -> crate::error::Result<()> {
1116        const STAMP_KEY: &str = "permissions_fingerprint";
1117        let mut names: Vec<&str> = self.entries.iter().map(|e| e.admin_name).collect();
1118        names.sort_unstable();
1119        let fingerprint = format!("{}|{}", env!("CARGO_PKG_VERSION"), names.join(","));
1120        crate::meta::ensure_table(db).await?;
1121        if crate::meta::get(db, STAMP_KEY).await?.as_deref() == Some(fingerprint.as_str()) {
1122            return Ok(());
1123        }
1124
1125        for entry in &self.entries {
1126            let singular = entry.singular_name.to_ascii_lowercase();
1127            crate::auth::register_model_permissions(db, entry.admin_name, &singular).await?;
1128            // PR 2.2 — grant the four model-CRUD permissions to the
1129            // three default groups per the grant matrix in
1130            // `auth::permissions::grant_model_to_default_groups`. No-op
1131            // when the seeded groups are absent (user-defined-groups
1132            // guard fired in `seed_default_groups`).
1133            crate::auth::grant_model_to_default_groups(db, entry.admin_name, &singular).await?;
1134        }
1135        crate::meta::set(db, STAMP_KEY, &fingerprint).await?;
1136        Ok(())
1137    }
1138}
1139
1140// -------------------------------------------------------------------------
1141// Core User entry — synthetic, route-only stub
1142// -------------------------------------------------------------------------
1143//
1144// Every project's admin index lists `Users` so operators can navigate
1145// to the bespoke `/admin/users/*` pages owned by `admin::builtin`. The
1146// `User` entry is built directly here rather than implementing
1147// `AdminModel` on a placeholder struct: the auth subsystem already
1148// owns the live `/admin/users` page with its own logic; routing
1149// through generic CRUD here would spawn a duplicate page.
1150
1151const CORE_USER_FIELDS: &[AdminField] = &[
1152    AdminField {
1153        name: "id",
1154        label: "id",
1155        field_type: FieldType::I64,
1156        editable: false,
1157        relation: None,
1158        choices: None,
1159    },
1160    AdminField {
1161        name: "email",
1162        label: "email",
1163        field_type: FieldType::String,
1164        editable: true,
1165        relation: None,
1166        choices: None,
1167    },
1168    AdminField {
1169        name: "password_hash",
1170        label: "password_hash",
1171        field_type: FieldType::String,
1172        editable: false,
1173        relation: None,
1174        choices: None,
1175    },
1176    AdminField {
1177        name: "role",
1178        label: "role",
1179        field_type: FieldType::String,
1180        editable: true,
1181        relation: None,
1182        choices: None,
1183    },
1184    AdminField {
1185        name: "is_active",
1186        label: "is_active",
1187        field_type: FieldType::Bool,
1188        editable: true,
1189        relation: None,
1190        choices: None,
1191    },
1192    AdminField {
1193        name: "created_at",
1194        label: "created_at",
1195        field_type: FieldType::DateTime,
1196        editable: false,
1197        relation: None,
1198        choices: None,
1199    },
1200];
1201
1202/// Normalise a user-supplied colour string to `#rrggbb` form. Accepts
1203/// both `"#1e6ba8"` and `"1e6ba8"`; trims whitespace; does NOT validate
1204/// that the body is hex (that's the renderer's job, where invalid
1205/// values fall back to the framework default rather than panic). The
1206/// `format!()` adds back exactly one leading `#`.
1207pub(crate) fn normalise_hex(input: impl Into<String>) -> String {
1208    let raw = input.into();
1209    let trimmed = raw.trim().trim_start_matches('#');
1210    format!("#{trimmed}")
1211}
1212
1213fn core_user_entry() -> AdminEntry {
1214    AdminEntry {
1215        admin_name: "users",
1216        display_name: "Users",
1217        singular_name: "User",
1218        table: "rustio_users",
1219        fields: CORE_USER_FIELDS,
1220        core: true,
1221        list_display: &[],
1222        list_filter: &[],
1223        search_fields: &[],
1224        search_index_column: None,
1225        ordering: &["-id"],
1226        list_per_page: 50,
1227        readonly_fields: &[],
1228        fieldsets: &[],
1229        bulk_actions: &[],
1230        inlines: &[],
1231        ops: Arc::new(CoreUserOps),
1232    }
1233}
1234
1235/// Route-only stub for the synthetic User entry. The live
1236/// `/admin/users` page is wired separately by `admin::builtin`, so
1237/// every method here returns a dedicated error rather than silently
1238/// half-working. If the generic admin ever routes to this, the error
1239/// makes the misuse obvious.
1240struct CoreUserOps;
1241
1242fn core_user_route_error() -> crate::error::Error {
1243    crate::error::Error::Internal(
1244        "the core User entry is route-only — use the dedicated /admin/users page".into(),
1245    )
1246}
1247
1248impl AdminOps for CoreUserOps {
1249    fn list<'a>(
1250        &'a self,
1251        _db: &'a Db,
1252        _opts: ListOpts,
1253    ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>> {
1254        Box::pin(async { Err(core_user_route_error()) })
1255    }
1256
1257    fn find_row<'a>(
1258        &'a self,
1259        _db: &'a Db,
1260        _id: i64,
1261    ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
1262        Box::pin(async { Err(core_user_route_error()) })
1263    }
1264
1265    fn create<'a>(&'a self, _db: &'a Db, _form: &'a FormData) -> CreateResult<'a> {
1266        Box::pin(async { Err(core_user_route_error()) })
1267    }
1268
1269    fn update<'a>(&'a self, _db: &'a Db, _id: i64, _form: &'a FormData) -> UpdateResult<'a> {
1270        Box::pin(async { Err(core_user_route_error()) })
1271    }
1272
1273    fn delete<'a>(
1274        &'a self,
1275        _db: &'a Db,
1276        _id: i64,
1277    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
1278        Box::pin(async { Err(core_user_route_error()) })
1279    }
1280
1281    fn object_label<'a>(
1282        &'a self,
1283        _db: &'a Db,
1284        _id: i64,
1285    ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>> {
1286        Box::pin(async { Err(core_user_route_error()) })
1287    }
1288
1289    fn execute_bulk_action<'a>(
1290        &'a self,
1291        _db: &'a Db,
1292        name: &'a str,
1293        _ids: &'a [i64],
1294        _ctx: &'a super::bulk::BulkActionContext<'a>,
1295    ) -> Pin<Box<dyn Future<Output = Result<super::bulk::BulkActionResult>> + Send + 'a>> {
1296        // The User entry has no project-owned bulk actions; the
1297        // framework manages user row writes through dedicated CLI /
1298        // admin routes. Surface the same "no project handler" shape
1299        // as the ModelAdmin default so a misconfigured registration
1300        // gets a clear error rather than a silent no-op.
1301        let owned = name.to_string();
1302        Box::pin(async move {
1303            Err(crate::error::Error::BadRequest(format!(
1304                "bulk action `{owned}` is not available on the framework User entry"
1305            )))
1306        })
1307    }
1308}
1309
1310// Test fixtures (PanicOps / FailingOps + AdminEntry::for_testing*) live
1311// with the legacy `admin/macro_tests.rs` etc. that haven't been ported
1312// yet. Re-add them here when the first in-tree test needs them.
1313
1314#[cfg(test)]
1315mod tests {
1316    use super::*;
1317    use crate::auth::{PasswordPolicy, PasswordPolicyError};
1318
1319    #[test]
1320    fn admin_new_installs_default_password_policy() {
1321        let admin = Admin::new();
1322        // Default floor is 10 (per DESIGN_RECOVERY.md §13.2).
1323        assert_eq!(admin.active_password_policy().min_length(), 10);
1324        // Sanity: a 9-char password is rejected, a 10-char is accepted.
1325        assert!(admin
1326            .active_password_policy()
1327            .validate("nine_char")
1328            .is_err());
1329        assert!(admin
1330            .active_password_policy()
1331            .validate("ten_chars_")
1332            .is_ok());
1333    }
1334
1335    #[test]
1336    fn admin_password_policy_overrides_default() {
1337        struct StubPolicy;
1338        impl PasswordPolicy for StubPolicy {
1339            fn validate(&self, _candidate: &str) -> std::result::Result<(), PasswordPolicyError> {
1340                Err(PasswordPolicyError::Custom("stub rejected".into()))
1341            }
1342            fn min_length(&self) -> usize {
1343                99
1344            }
1345        }
1346
1347        let admin = Admin::new().password_policy(Arc::new(StubPolicy));
1348        assert_eq!(admin.active_password_policy().min_length(), 99);
1349        let err = admin
1350            .active_password_policy()
1351            .validate("anything-at-all-here")
1352            .unwrap_err();
1353        assert_eq!(err, PasswordPolicyError::Custom("stub rejected".into()));
1354    }
1355
1356    #[test]
1357    fn admin_new_installs_default_recovery_policy() {
1358        let admin = Admin::new();
1359        let p = admin.active_recovery_policy();
1360        // Locked defaults from DESIGN_RECOVERY.md §17.
1361        assert_eq!(p.reset_token_ttl(), chrono::Duration::hours(1));
1362        assert_eq!(
1363            p.request_rate_limit(),
1364            (5, std::time::Duration::from_secs(15 * 60))
1365        );
1366        assert_eq!(
1367            p.consume_rate_limit(),
1368            (10, std::time::Duration::from_secs(5 * 60))
1369        );
1370        assert!(!p.strict_mailer_required());
1371    }
1372
1373    #[test]
1374    fn admin_new_has_no_custom_mailer() {
1375        let admin = Admin::new();
1376        assert!(!admin.has_custom_mailer());
1377    }
1378
1379    #[test]
1380    fn admin_mailer_builder_flips_override_flag() {
1381        // Even when the override happens to register another LogMailer,
1382        // the explicit call is what the strict-mailer guard reads.
1383        let admin = Admin::new().mailer(Arc::new(crate::email::LogMailer));
1384        assert!(admin.has_custom_mailer());
1385    }
1386
1387    #[test]
1388    fn admin_recovery_policy_overrides_default() {
1389        use crate::auth::RecoveryPolicy;
1390
1391        struct StubRecoveryPolicy;
1392        impl RecoveryPolicy for StubRecoveryPolicy {
1393            fn reset_token_ttl(&self) -> chrono::Duration {
1394                chrono::Duration::hours(2)
1395            }
1396            fn request_rate_limit(&self) -> (u32, std::time::Duration) {
1397                (1, std::time::Duration::from_secs(60))
1398            }
1399            fn consume_rate_limit(&self) -> (u32, std::time::Duration) {
1400                (2, std::time::Duration::from_secs(120))
1401            }
1402            fn strict_mailer_required(&self) -> bool {
1403                true
1404            }
1405            // public_site_url uses the trait's provided default.
1406        }
1407
1408        let admin = Admin::new().recovery_policy(Arc::new(StubRecoveryPolicy));
1409        let p = admin.active_recovery_policy();
1410        assert_eq!(p.reset_token_ttl(), chrono::Duration::hours(2));
1411        assert_eq!(
1412            p.request_rate_limit(),
1413            (1, std::time::Duration::from_secs(60))
1414        );
1415        assert_eq!(
1416            p.consume_rate_limit(),
1417            (2, std::time::Duration::from_secs(120))
1418        );
1419        assert!(p.strict_mailer_required());
1420    }
1421}
1422
1423/// End-to-end coverage for the `float` / `date` / `time` / `decimal` /
1424/// `uuid` field types.
1425///
1426/// Deriving `RustioAdmin` on a struct that uses `f64` / `NaiveDate` /
1427/// `NaiveTime` / `Decimal` / `Uuid` is the only thing in this crate
1428/// that instantiates the macro's `classify_type`, `display_values`,
1429/// and `from_form` arms for these Rust types — no other in-crate model
1430/// uses them, so without this fixture a bug in those expansions would
1431/// ship uncaught.
1432#[cfg(test)]
1433mod scalar_field_type_tests {
1434    use super::FieldType;
1435    use crate::admin::AdminModel;
1436    use crate::http::FormData;
1437    use crate::RustioAdmin;
1438    use chrono::{NaiveDate, NaiveTime};
1439    use rust_decimal::Decimal;
1440    use std::str::FromStr;
1441    use uuid::Uuid;
1442
1443    #[derive(Debug, RustioAdmin)]
1444    struct Measurement {
1445        id: i64,
1446        weight_kg: f64,
1447        unit_price: Decimal,
1448        taken_on: NaiveDate,
1449        taken_at: NaiveTime,
1450        public_id: Uuid,
1451        #[rustio(format = "email")]
1452        contact_email: String,
1453        #[rustio(format = "phone")]
1454        contact_phone: String,
1455        #[rustio(choices = ["active", "archived"])]
1456        status: String,
1457    }
1458
1459    const SAMPLE_UUID: &str = "550e8400-e29b-41d4-a716-446655440000";
1460
1461    fn field_type(name: &str) -> FieldType {
1462        Measurement::FIELDS
1463            .iter()
1464            .find(|f| f.name == name)
1465            .unwrap_or_else(|| panic!("field {name} present"))
1466            .field_type
1467    }
1468
1469    #[test]
1470    fn classify_maps_each_rust_type_to_its_field_type() {
1471        assert_eq!(field_type("weight_kg"), FieldType::F64);
1472        assert_eq!(field_type("unit_price"), FieldType::Decimal);
1473        assert_eq!(field_type("taken_on"), FieldType::Date);
1474        assert_eq!(field_type("taken_at"), FieldType::Time);
1475        assert_eq!(field_type("public_id"), FieldType::Uuid);
1476        // email / phone are `String` in Rust — the `#[rustio(format)]`
1477        // attribute is the only thing that distinguishes them.
1478        assert_eq!(field_type("contact_email"), FieldType::Email);
1479        assert_eq!(field_type("contact_phone"), FieldType::Phone);
1480        // A choice column stays `String` at the type level — the
1481        // `<select>` is driven by `AdminField.choices`, not FieldType.
1482        assert_eq!(field_type("status"), FieldType::String);
1483    }
1484
1485    #[test]
1486    fn choice_field_populates_adminfield_choices() {
1487        let status = Measurement::FIELDS
1488            .iter()
1489            .find(|f| f.name == "status")
1490            .expect("status field present");
1491        assert_eq!(status.choices, Some(&["active", "archived"][..]));
1492        // Fields without `#[rustio(choices)]` carry no choice list.
1493        let email = Measurement::FIELDS
1494            .iter()
1495            .find(|f| f.name == "contact_email")
1496            .unwrap();
1497        assert_eq!(email.choices, None);
1498    }
1499
1500    #[test]
1501    fn widgets_follow_html_input_types() {
1502        assert_eq!(FieldType::F64.widget(), "number");
1503        assert_eq!(FieldType::Decimal.widget(), "number");
1504        assert_eq!(FieldType::Date.widget(), "date");
1505        assert_eq!(FieldType::Time.widget(), "time");
1506        assert_eq!(FieldType::Uuid.widget(), "text");
1507        assert_eq!(FieldType::Email.widget(), "email");
1508        assert_eq!(FieldType::Phone.widget(), "tel");
1509    }
1510
1511    #[test]
1512    fn from_form_parses_valid_scalars() {
1513        let form = FormData::from_urlencoded(&format!(
1514            "weight_kg=72.5&unit_price=19.99&taken_on=2026-06-02&taken_at=09:30&public_id={SAMPLE_UUID}\
1515             &contact_email=alice@example.com&contact_phone=%2B1%20555-123-4567&status=active"
1516        ));
1517        let m = Measurement::from_form(&form).expect("valid form parses");
1518        assert_eq!(m.weight_kg, 72.5);
1519        assert_eq!(m.unit_price, Decimal::from_str("19.99").unwrap());
1520        assert_eq!(m.taken_on, NaiveDate::from_ymd_opt(2026, 6, 2).unwrap());
1521        assert_eq!(m.taken_at, NaiveTime::from_hms_opt(9, 30, 0).unwrap());
1522        assert_eq!(m.public_id, Uuid::parse_str(SAMPLE_UUID).unwrap());
1523        assert_eq!(m.contact_email, "alice@example.com");
1524        assert_eq!(m.contact_phone, "+1 555-123-4567");
1525        assert_eq!(m.status, "active");
1526    }
1527
1528    #[test]
1529    fn display_values_use_html_roundtrip_formats() {
1530        let m = Measurement {
1531            id: 1,
1532            weight_kg: 72.5,
1533            unit_price: Decimal::from_str("19.99").unwrap(),
1534            taken_on: NaiveDate::from_ymd_opt(2026, 6, 2).unwrap(),
1535            taken_at: NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
1536            public_id: Uuid::parse_str(SAMPLE_UUID).unwrap(),
1537            contact_email: "alice@example.com".to_string(),
1538            contact_phone: "+1 555-123-4567".to_string(),
1539            status: "active".to_string(),
1540        };
1541        let vals: std::collections::HashMap<String, String> =
1542            m.display_values().into_iter().collect();
1543        assert_eq!(vals["weight_kg"], "72.5");
1544        assert_eq!(vals["unit_price"], "19.99");
1545        assert_eq!(vals["taken_on"], "2026-06-02");
1546        assert_eq!(vals["taken_at"], "09:30");
1547        assert_eq!(vals["public_id"], SAMPLE_UUID);
1548        assert_eq!(vals["contact_email"], "alice@example.com");
1549        assert_eq!(vals["contact_phone"], "+1 555-123-4567");
1550        assert_eq!(vals["status"], "active");
1551    }
1552
1553    #[test]
1554    fn from_form_rejects_invalid_scalars() {
1555        let form = FormData::from_urlencoded(
1556            "weight_kg=heavy&unit_price=cheap&taken_on=not-a-date&taken_at=99%3A99&public_id=not-a-uuid\
1557             &contact_email=nope&contact_phone=call-me&status=deleted",
1558        );
1559        let errs = Measurement::from_form(&form).unwrap_err();
1560        // 7 malformed scalars + 1 out-of-set choice value.
1561        assert_eq!(errs.len(), 8, "one error per malformed field: {errs:?}");
1562    }
1563
1564    #[test]
1565    fn choice_from_form_accepts_in_set_rejects_out_of_set() {
1566        let base = format!(
1567            "weight_kg=1&unit_price=1&taken_on=2026-06-02&taken_at=00:00&public_id={SAMPLE_UUID}\
1568             &contact_email=a@b.co&contact_phone=0701234567"
1569        );
1570        // In-set value parses clean.
1571        let ok = FormData::from_urlencoded(&format!("{base}&status=archived"));
1572        assert_eq!(Measurement::from_form(&ok).unwrap().status, "archived");
1573        // Out-of-set value is the only error.
1574        let bad = FormData::from_urlencoded(&format!("{base}&status=pending"));
1575        let errs = Measurement::from_form(&bad).unwrap_err();
1576        assert_eq!(errs.len(), 1, "{errs:?}");
1577        assert!(errs[0].contains("must be one of: active, archived"));
1578    }
1579}