Skip to main content

rustio_admin/admin/
types.rs

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