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