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