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::error::Result;
14use crate::http::FormData;
15use crate::orm::{Db, Value};
16
17pub(crate) type CreateResult<'a> =
18    Pin<Box<dyn Future<Output = Result<std::result::Result<i64, Vec<String>>>> + Send + 'a>>;
19
20pub(crate) type UpdateResult<'a> =
21    Pin<Box<dyn Future<Output = Result<std::result::Result<(), Vec<String>>>> + Send + 'a>>;
22
23// ---------------------------------------------------------------------------
24// User profile extension API
25// ---------------------------------------------------------------------------
26
27/// One labeled section rendered in the project-extension area of the
28/// built-in user profile page (admin/user_view.html — `{% block
29/// project_user_fields %}`). A project's extension closure returns
30/// `Vec<UserProfileSection>` so it can contribute multiple disjoint
31/// areas in a single registration.
32#[derive(Debug, Clone, serde::Serialize)]
33pub struct UserProfileSection {
34    pub label: String,
35    pub rows: Vec<UserProfileRow>,
36}
37
38/// One key-value row inside a [`UserProfileSection`]. Both fields are
39/// `String` so projects can format whatever shape they need. Rendered
40/// escaped — pass plain text; for arbitrary HTML, projects override
41/// the template block instead.
42#[derive(Debug, Clone, serde::Serialize)]
43pub struct UserProfileRow {
44    pub label: String,
45    pub value: String,
46}
47
48/// The boxed-closure shape stored on `Admin`. `pub(crate)` because
49/// projects use the generic [`Admin::user_profile_extension`] builder
50/// method and never have to name this directly.
51pub(crate) type UserProfileExtensionFn =
52    Arc<dyn Fn(Db, crate::auth::UserProfile) -> UserProfileExtensionFuture + Send + Sync + 'static>;
53
54pub(crate) type UserProfileExtensionFuture =
55    Pin<Box<dyn Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static>>;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum FieldType {
60    I32,
61    I64,
62    Bool,
63    String,
64    DateTime,
65    OptionalI64,
66    OptionalString,
67    OptionalDateTime,
68}
69
70impl FieldType {
71    pub fn widget(&self) -> &'static str {
72        match self {
73            FieldType::Bool => "checkbox",
74            FieldType::DateTime | FieldType::OptionalDateTime => "datetime",
75            FieldType::I32 | FieldType::I64 | FieldType::OptionalI64 => "number",
76            FieldType::String | FieldType::OptionalString => "text",
77        }
78    }
79
80    pub fn nullable(&self) -> bool {
81        matches!(
82            self,
83            FieldType::OptionalI64 | FieldType::OptionalString | FieldType::OptionalDateTime
84        )
85    }
86}
87
88#[derive(Debug, Clone)]
89pub struct AdminField {
90    pub name: &'static str,
91    pub label: &'static str,
92    pub field_type: FieldType,
93    pub editable: bool,
94    pub relation: Option<AdminRelation>,
95    /// Closed list of allowed string values for this field. When
96    /// `Some`, the form layer renders a `<select>` with one option per
97    /// entry. The values double as labels (raw, not humanised) per
98    /// the "no invented content" rule.
99    pub choices: Option<&'static [&'static str]>,
100}
101
102#[derive(Debug, Clone)]
103pub struct AdminRelation {
104    pub target_model: &'static str,
105    pub display_field: Option<&'static str>,
106    /// `true` for many-to-many relations (form renders
107    /// `<select multiple>`), `false` for the default belongs-to
108    /// (single `<select>`). Macro emits `false`; consumers that want
109    /// M2M behaviour must hand-set this until the macro learns a
110    /// `#[rustio(many_to_many)]` attribute.
111    pub multi: bool,
112}
113
114/// What the `#[derive(RustioAdmin)]` macro produces for each struct.
115pub trait AdminModel: Send + Sync + 'static {
116    const ADMIN_NAME: &'static str;
117    const DISPLAY_NAME: &'static str;
118    const SINGULAR_NAME: &'static str;
119    const FIELDS: &'static [AdminField];
120
121    /// Render one row for the list page (column → display string).
122    fn display_values(&self) -> Vec<(String, String)>;
123
124    /// Populate a new instance from an HTTP form. Returns a list of
125    /// validation errors if anything was wrong.
126    fn from_form(form: &FormData) -> std::result::Result<Self, Vec<String>>
127    where
128        Self: Sized;
129
130    /// A stable label for one instance (used on the delete confirm page).
131    fn object_label(&self) -> String;
132
133    fn id(&self) -> i64;
134
135    fn values_to_update(&self) -> Vec<(&'static str, Value)>;
136}
137
138/// Runtime metadata about one admin-registered model. Captures both
139/// the [`AdminModel`] static surface and the [`super::ModelAdmin`]
140/// customisation values at registration time, so handlers read every
141/// per-model knob from this struct instead of re-resolving traits.
142pub struct AdminEntry {
143    pub admin_name: &'static str,
144    pub display_name: &'static str,
145    pub singular_name: &'static str,
146    /// SQL table name. For user-registered models this is `<M as Model>::TABLE`;
147    /// for the synthetic core User entry it's `"rustio_users"`.
148    pub table: &'static str,
149    pub fields: &'static [AdminField],
150    /// `true` only for framework-owned entries (currently just `User`).
151    pub core: bool,
152    /// `ModelAdmin::list_display()`. Empty → use every column on
153    /// `fields`; non-empty → use exactly the listed names in order.
154    pub list_display: &'static [&'static str],
155    /// `ModelAdmin::list_filter()`. Empty by default.
156    pub list_filter: &'static [&'static str],
157    /// `ModelAdmin::search_fields()`. Empty by default.
158    pub search_fields: &'static [&'static str],
159    /// `ModelAdmin::ordering()`. Strings parsed via
160    /// [`super::modeladmin::parse_order_spec`].
161    pub ordering: &'static [&'static str],
162    /// `ModelAdmin::list_per_page()`. Default 50.
163    pub list_per_page: usize,
164    /// `ModelAdmin::readonly_fields()`. Empty by default.
165    pub readonly_fields: &'static [&'static str],
166    /// `ModelAdmin::fieldsets()`. Empty → fall back to the
167    /// framework's name-heuristic grouping.
168    pub fieldsets: &'static [super::modeladmin::Fieldset],
169    /// `ModelAdmin::bulk_actions()`. Empty by default — the bulk bar
170    /// only renders the framework's built-in Delete.
171    pub bulk_actions: &'static [super::modeladmin::BulkAction],
172    pub(crate) ops: Arc<dyn AdminOps>,
173}
174
175/// Per-request options for [`AdminOps::list`]. Empty / `None` fields
176/// mean "framework default": no ordering override falls back to
177/// `id DESC` inside the runtime, no filters skips the WHERE clause,
178/// no limit fetches every row.
179#[derive(Debug, Clone, Default)]
180pub struct ListOpts {
181    /// Validated `(column, dir)` pairs to apply as `ORDER BY`. The
182    /// column name is bound to the model's `M::COLUMNS` set inside
183    /// the runtime, so callers can pass user-supplied names without
184    /// SQL-injection risk.
185    pub ordering: Vec<(String, super::modeladmin::SortDir)>,
186    /// `(column, value)` pairs applied as `WHERE col::text = $N`.
187    /// Cast to text so the comparison matches the same string-shape
188    /// semantics the in-memory pre-P10 filter used for bool / int /
189    /// timestamp columns.
190    pub filters: Vec<(String, String)>,
191    /// Free-text search: `(term, columns)`. The runtime emits
192    /// `WHERE (col1::text ILIKE $N OR col2::text ILIKE $N OR …)`
193    /// with `$N = '%term%'`. An empty `term` or empty `columns`
194    /// leaves the WHERE alone.
195    pub search: Option<(String, Vec<String>)>,
196    /// `LIMIT $N` for the data query. The COUNT(*) query never
197    /// applies it. `None` → no limit.
198    pub limit: Option<i64>,
199    /// `OFFSET $N` for the data query. `None` or `Some(0)` → no offset.
200    pub offset: Option<i64>,
201}
202
203/// Result of [`AdminOps::list`]: the requested page plus the total
204/// row count under the same WHERE clause (so handlers can render
205/// pagination footers without a separate query).
206#[derive(Debug, Default)]
207pub struct ListPage {
208    pub rows: Vec<ListRow>,
209    pub total: i64,
210}
211
212/// Type-erased CRUD operations. The `Admin::model::<M>()` call captures
213/// a concrete `M: AdminModel + Model` and hides it behind this trait so
214/// the router can treat every model uniformly. The single live impl is
215/// [`super::ops::ConcreteOps<M>`].
216pub(crate) trait AdminOps: Send + Sync {
217    fn list<'a>(
218        &'a self,
219        db: &'a Db,
220        opts: ListOpts,
221    ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>>;
222
223    fn find_row<'a>(
224        &'a self,
225        db: &'a Db,
226        id: i64,
227    ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>>;
228
229    fn create<'a>(&'a self, db: &'a Db, form: &'a FormData) -> CreateResult<'a>;
230
231    fn update<'a>(&'a self, db: &'a Db, id: i64, form: &'a FormData) -> UpdateResult<'a>;
232
233    fn delete<'a>(
234        &'a self,
235        db: &'a Db,
236        id: i64,
237    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
238
239    fn object_label<'a>(
240        &'a self,
241        db: &'a Db,
242        id: i64,
243    ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>>;
244
245    /// Run a project-defined bulk action against the supplied row
246    /// ids. Called once per submission with the full id list, so the
247    /// implementation can choose between a single bulk SQL update or
248    /// a per-row loop. The default impl returns `BadRequest` with the
249    /// action name embedded — projects override to match on `name`
250    /// and apply the work; an unknown name surfaces as a clear error
251    /// page rather than a silent no-op.
252    ///
253    /// Note: the framework's built-in `delete` action is **not**
254    /// dispatched through here. It runs through the cascade-aware
255    /// `/bulk_delete` route which calls `delete()` per row. Override
256    /// `delete` instead if you need custom delete semantics.
257    fn execute_bulk_action<'a>(
258        &'a self,
259        _db: &'a Db,
260        name: &'a str,
261        _ids: &'a [i64],
262    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
263        let owned = name.to_string();
264        Box::pin(async move {
265            Err(crate::error::Error::BadRequest(format!(
266                "bulk action `{owned}` has no project handler — override \
267                 AdminOps::execute_bulk_action on this model to implement it"
268            )))
269        })
270    }
271}
272
273/// A row as shown on the list page.
274#[derive(Debug)]
275pub struct ListRow {
276    pub id: i64,
277    pub cells: Vec<String>,
278    /// Optional link target per cell, parallel to `cells`. When
279    /// `Some`, the renderer wraps that cell's content in an
280    /// `<a href="/admin/{admin_name}/{id}/edit">…</a>` so foreign-key
281    /// columns become click-throughs to the related row. Populated by
282    /// the post-list hydration pass in `handlers::hydrate_fk_cells`;
283    /// `ConcreteOps::list` always emits a parallel vector of `None` of
284    /// matching length so callers that skip hydration still satisfy
285    /// the parallel-vector invariant.
286    pub cell_links: Vec<Option<CellLink>>,
287}
288
289/// One resolved foreign-key cell. The renderer turns this into
290/// `<a href="/admin/{admin_name}/{id}/edit">…</a>` around the cell's
291/// display label.
292#[derive(Debug, Clone)]
293pub struct CellLink {
294    /// Target model's admin slug (e.g. `"categories"` for `Category`).
295    pub admin_name: String,
296    /// Target row id.
297    pub id: i64,
298}
299
300/// The raw field values used to pre-fill the edit form.
301#[derive(Debug)]
302pub struct EditRow {
303    #[allow(dead_code)]
304    pub id: i64,
305    pub values: Vec<(String, String)>,
306}
307
308/// Per-project admin branding. Defaults are RustIO-flavoured;
309/// projects override via [`Admin::site_branding`].
310#[derive(Clone, Debug)]
311pub struct SiteBranding {
312    pub site_title: String,
313    pub site_header: String,
314    pub index_title: String,
315    pub footer_copyright: String,
316    /// DNS-shape string available to project handlers; not surfaced in
317    /// any framework template.
318    pub domain: String,
319}
320
321impl Default for SiteBranding {
322    fn default() -> Self {
323        Self {
324            site_title: "RustIO administration".into(),
325            site_header: "RustIO administration".into(),
326            index_title: "Site administration".into(),
327            footer_copyright: format!("RustIO {}", env!("CARGO_PKG_VERSION")),
328            domain: "rustio.local".into(),
329        }
330    }
331}
332
333/// Project-level override patch for the admin chrome palette.
334///
335/// `admin.css` is the single source of truth for the framework's design
336/// tokens (light defaults, dark mode, semantic surfaces, typography
337/// scale, …). `AdminTheme` is **purely a patch layer**: every field is
338/// `Option<String>` and defaults to `None`, meaning *“don’t override —
339/// let the stylesheet decide.”* Out of the box the framework emits no
340/// inline `<style>` block at all.
341///
342/// Set a field — usually via the fluent builder methods or
343/// [`Admin::accent_color`] — to inject a `--rio-*` custom-property
344/// override on every page. Overrides apply across `data-rio-theme`
345/// states (system / light / dark) by emitting a multi-state selector
346/// after `admin.css`, so they win cascade ties without `!important`.
347///
348/// Values are hex (`#rrggbb` or `rrggbb`); the leading `#` is
349/// auto-normalised at construction. Malformed input is rejected at
350/// override time rather than panicking — the admin path never breaks
351/// over a config typo.
352#[derive(Clone, Debug, Default, PartialEq, Eq)]
353pub struct AdminTheme {
354    pub accent: Option<String>,
355    pub bg: Option<String>,
356    pub surface: Option<String>,
357    pub text: Option<String>,
358    pub text_muted: Option<String>,
359    pub border: Option<String>,
360}
361
362impl AdminTheme {
363    /// New empty patch — no overrides emitted, `admin.css` wins.
364    pub fn new() -> Self {
365        Self::default()
366    }
367
368    /// `true` when at least one field is set. Used by the renderer to
369    /// decide whether to emit the inline `<style>` block at all.
370    pub fn has_overrides(&self) -> bool {
371        self.accent.is_some()
372            || self.bg.is_some()
373            || self.surface.is_some()
374            || self.text.is_some()
375            || self.text_muted.is_some()
376            || self.border.is_some()
377    }
378
379    /// Override `--rio-accent`. Hex form, `#` optional.
380    pub fn accent(mut self, color: impl Into<String>) -> Self {
381        self.accent = Some(normalise_hex(color));
382        self
383    }
384
385    /// Override `--rio-bg` (page canvas).
386    pub fn bg(mut self, color: impl Into<String>) -> Self {
387        self.bg = Some(normalise_hex(color));
388        self
389    }
390
391    /// Override `--rio-surface` (cards, topbar, sidebar, table body).
392    pub fn surface(mut self, color: impl Into<String>) -> Self {
393        self.surface = Some(normalise_hex(color));
394        self
395    }
396
397    /// Override `--rio-text` (body text colour).
398    pub fn text(mut self, color: impl Into<String>) -> Self {
399        self.text = Some(normalise_hex(color));
400        self
401    }
402
403    /// Override `--rio-text-muted` (secondary text, breadcrumb links).
404    pub fn text_muted(mut self, color: impl Into<String>) -> Self {
405        self.text_muted = Some(normalise_hex(color));
406        self
407    }
408
409    /// Override `--rio-border` (default divider, card outline).
410    pub fn border(mut self, color: impl Into<String>) -> Self {
411        self.border = Some(normalise_hex(color));
412        self
413    }
414}
415
416/// Builder for the admin. Register models with `.model::<M>()`, then
417/// hand it to the router via `register_admin_routes`.
418pub struct Admin {
419    pub(crate) entries: Vec<AdminEntry>,
420    pub(crate) site_branding: SiteBranding,
421    pub(crate) user_profile_ext: Option<UserProfileExtensionFn>,
422    pub(crate) theme: AdminTheme,
423}
424
425impl Default for Admin {
426    fn default() -> Self {
427        Self::new()
428    }
429}
430
431impl Admin {
432    /// Constructs a new `Admin` with the framework's core entries
433    /// pre-seeded. The only core entry is `User`; project models are
434    /// added on top via [`Self::model`].
435    pub fn new() -> Self {
436        Self {
437            entries: vec![core_user_entry()],
438            site_branding: SiteBranding::default(),
439            user_profile_ext: None,
440            theme: AdminTheme::default(),
441        }
442    }
443
444    /// Override the default RustIO branding.
445    pub fn site_branding(mut self, branding: SiteBranding) -> Self {
446        self.site_branding = branding;
447        self
448    }
449
450    /// Read-only access to the active branding.
451    pub fn branding(&self) -> &SiteBranding {
452        &self.site_branding
453    }
454
455    /// Set the admin chrome's accent colour. Hex form, with or without
456    /// the leading `#` (`"#1e6ba8"` and `"1e6ba8"` both work). Replaces
457    /// any prior accent override; other [`AdminTheme`] fields are
458    /// left untouched.
459    pub fn accent_color(mut self, color: impl Into<String>) -> Self {
460        self.theme.accent = Some(normalise_hex(color));
461        self
462    }
463
464    /// Replace the entire admin chrome palette patch in one call. See
465    /// [`AdminTheme`] for the field-by-field contract.
466    pub fn theme(mut self, theme: AdminTheme) -> Self {
467        self.theme = theme;
468        self
469    }
470
471    /// Read-only access to the configured accent colour, if any. `None`
472    /// means *“no override — admin.css owns it”*.
473    pub fn accent(&self) -> Option<&str> {
474        self.theme.accent.as_deref()
475    }
476
477    /// Read-only access to the active theme override patch.
478    pub fn active_theme(&self) -> &AdminTheme {
479        &self.theme
480    }
481
482    pub fn model<M>(mut self) -> Self
483    where
484        M: super::ModelAdmin + crate::orm::Model,
485    {
486        let ops: Arc<dyn AdminOps> = Arc::new(super::ops::ConcreteOps::<M>::new());
487        self.entries.push(AdminEntry {
488            admin_name: M::ADMIN_NAME,
489            display_name: M::DISPLAY_NAME,
490            singular_name: M::SINGULAR_NAME,
491            table: <M as crate::orm::Model>::TABLE,
492            fields: M::FIELDS,
493            core: false,
494            list_display: M::list_display(),
495            list_filter: M::list_filter(),
496            search_fields: M::search_fields(),
497            ordering: M::ordering(),
498            list_per_page: M::list_per_page(),
499            readonly_fields: M::readonly_fields(),
500            fieldsets: M::fieldsets(),
501            bulk_actions: M::bulk_actions(),
502            ops,
503        });
504        self
505    }
506
507    pub fn entries(&self) -> &[AdminEntry] {
508        &self.entries
509    }
510
511    /// Register a project-specific extension that contributes extra
512    /// sections to the built-in user profile page. The closure is
513    /// invoked on every render of `GET /admin/users/:id` (Overview tab);
514    /// it receives the `Db` handle and the loaded
515    /// [`crate::auth::UserProfile`] (no `password_hash`) and returns a
516    /// `Vec<UserProfileSection>`. Sections render in the order returned,
517    /// immediately after the core profile show-grid.
518    ///
519    /// Zero-config baseline: don't call this method, and the extension
520    /// area stays empty. Projects that need richer layout than key-value
521    /// rows override the `{% block project_user_fields %}` template
522    /// block in `templates/admin/user_view.html` instead.
523    pub fn user_profile_extension<F, Fut>(mut self, ext: F) -> Self
524    where
525        F: Fn(Db, crate::auth::UserProfile) -> Fut + Send + Sync + 'static,
526        Fut: Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static,
527    {
528        self.user_profile_ext = Some(Arc::new(move |db, user| Box::pin(ext(db, user))));
529        self
530    }
531
532    /// Internal accessor — handlers fetch the registered extension
533    /// closure (if any) here. Used by `admin/builtin.rs` (P6.b).
534    #[allow(dead_code)]
535    pub(crate) fn user_profile_ext(&self) -> Option<&UserProfileExtensionFn> {
536        self.user_profile_ext.as_ref()
537    }
538
539    pub fn find(&self, admin_name: &str) -> Option<&AdminEntry> {
540        self.entries.iter().find(|e| e.admin_name == admin_name)
541    }
542
543    /// Register the canonical (add/change/delete/view) permissions for
544    /// every model. Call during startup after `init_tables`.
545    pub async fn seed_permissions(&self, db: &crate::orm::Db) -> crate::error::Result<()> {
546        for entry in &self.entries {
547            let singular = entry.singular_name.to_ascii_lowercase();
548            crate::auth::register_model_permissions(db, entry.admin_name, &singular).await?;
549        }
550        Ok(())
551    }
552}
553
554// -------------------------------------------------------------------------
555// Core User entry — synthetic, route-only stub
556// -------------------------------------------------------------------------
557//
558// Every project's admin index lists `Users` so operators can navigate
559// to the bespoke `/admin/users/*` pages owned by `admin::builtin`. The
560// `User` entry is built directly here rather than implementing
561// `AdminModel` on a placeholder struct: the auth subsystem already
562// owns the live `/admin/users` page with its own logic; routing
563// through generic CRUD here would spawn a duplicate page.
564
565const CORE_USER_FIELDS: &[AdminField] = &[
566    AdminField {
567        name: "id",
568        label: "id",
569        field_type: FieldType::I64,
570        editable: false,
571        relation: None,
572        choices: None,
573    },
574    AdminField {
575        name: "email",
576        label: "email",
577        field_type: FieldType::String,
578        editable: true,
579        relation: None,
580        choices: None,
581    },
582    AdminField {
583        name: "password_hash",
584        label: "password_hash",
585        field_type: FieldType::String,
586        editable: false,
587        relation: None,
588        choices: None,
589    },
590    AdminField {
591        name: "role",
592        label: "role",
593        field_type: FieldType::String,
594        editable: true,
595        relation: None,
596        choices: None,
597    },
598    AdminField {
599        name: "is_active",
600        label: "is_active",
601        field_type: FieldType::Bool,
602        editable: true,
603        relation: None,
604        choices: None,
605    },
606    AdminField {
607        name: "created_at",
608        label: "created_at",
609        field_type: FieldType::DateTime,
610        editable: false,
611        relation: None,
612        choices: None,
613    },
614];
615
616/// Normalise a user-supplied colour string to `#rrggbb` form. Accepts
617/// both `"#1e6ba8"` and `"1e6ba8"`; trims whitespace; does NOT validate
618/// that the body is hex (that's the renderer's job, where invalid
619/// values fall back to the framework default rather than panic). The
620/// `format!()` adds back exactly one leading `#`.
621pub(crate) fn normalise_hex(input: impl Into<String>) -> String {
622    let raw = input.into();
623    let trimmed = raw.trim().trim_start_matches('#');
624    format!("#{trimmed}")
625}
626
627fn core_user_entry() -> AdminEntry {
628    AdminEntry {
629        admin_name: "users",
630        display_name: "Users",
631        singular_name: "User",
632        table: "rustio_users",
633        fields: CORE_USER_FIELDS,
634        core: true,
635        list_display: &[],
636        list_filter: &[],
637        search_fields: &[],
638        ordering: &["-id"],
639        list_per_page: 50,
640        readonly_fields: &[],
641        fieldsets: &[],
642        bulk_actions: &[],
643        ops: Arc::new(CoreUserOps),
644    }
645}
646
647/// Route-only stub for the synthetic User entry. The live
648/// `/admin/users` page is wired separately by `admin::builtin`, so
649/// every method here returns a dedicated error rather than silently
650/// half-working. If the generic admin ever routes to this, the error
651/// makes the misuse obvious.
652struct CoreUserOps;
653
654fn core_user_route_error() -> crate::error::Error {
655    crate::error::Error::Internal(
656        "the core User entry is route-only — use the dedicated /admin/users page".into(),
657    )
658}
659
660impl AdminOps for CoreUserOps {
661    fn list<'a>(
662        &'a self,
663        _db: &'a Db,
664        _opts: ListOpts,
665    ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>> {
666        Box::pin(async { Err(core_user_route_error()) })
667    }
668
669    fn find_row<'a>(
670        &'a self,
671        _db: &'a Db,
672        _id: i64,
673    ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
674        Box::pin(async { Err(core_user_route_error()) })
675    }
676
677    fn create<'a>(&'a self, _db: &'a Db, _form: &'a FormData) -> CreateResult<'a> {
678        Box::pin(async { Err(core_user_route_error()) })
679    }
680
681    fn update<'a>(&'a self, _db: &'a Db, _id: i64, _form: &'a FormData) -> UpdateResult<'a> {
682        Box::pin(async { Err(core_user_route_error()) })
683    }
684
685    fn delete<'a>(
686        &'a self,
687        _db: &'a Db,
688        _id: i64,
689    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
690        Box::pin(async { Err(core_user_route_error()) })
691    }
692
693    fn object_label<'a>(
694        &'a self,
695        _db: &'a Db,
696        _id: i64,
697    ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>> {
698        Box::pin(async { Err(core_user_route_error()) })
699    }
700}
701
702// Test fixtures (PanicOps / FailingOps + AdminEntry::for_testing*) live
703// with the legacy `admin/macro_tests.rs` etc. that haven't been ported
704// yet. Re-add them here when the first in-tree test needs them.