Skip to main content

tablo_core/panel/
mod.rs

1//! `Panel` — an admin panel an app mounts into its router.
2
3mod actions;
4mod build;
5mod detail;
6mod forms;
7pub(crate) mod gate;
8mod headers;
9mod list;
10mod pages;
11mod register;
12mod relations;
13mod search;
14mod shell;
15pub(crate) mod state;
16#[cfg(test)]
17pub(crate) mod test_support;
18pub mod url;
19mod write;
20
21use std::path::PathBuf;
22
23use topcoat::{asset::Asset, font::Font, router::LayoutRenderFn};
24
25#[cfg(test)]
26pub(crate) use self::search::TABLE_SEARCH_PATH;
27pub use self::{build::RouterBuilderPanelExt, gate::can_list, shell::Brand};
28use self::{
29    build::is_directory_pattern,
30    register::{PageRegistration, Registration, ResourceRegistration},
31    shell::ShellAssets,
32};
33pub(crate) use self::{build::route_path, forms::parse_form_body, gate::panel_prefix};
34use crate::{
35    DeclarationError, DeclarationErrorKind, Page,
36    resource::{Resource, ResourceDef},
37};
38
39/// Returns the panel's list table for `R` for a page that owns its table; pair it with
40/// [`TablePage::load`](crate::table::TablePage::load) and
41/// [`Table::render_with_state`](crate::table::Table::render_with_state).
42///
43/// # Errors
44///
45/// A declaration error when the request's panel does not mount `R`.
46pub fn wired_table<R: Resource>(
47    cx: &topcoat::context::Cx,
48) -> topcoat::Result<crate::table::Table<R::Model>> {
49    let resource = crate::resource::require_mounted::<R>(cx)?;
50    Ok(self::list::wire_table_actions(cx, &resource, false))
51}
52
53/// `R`'s sidebar entry in the request's panel, with its label, URL and icon; `None` when the
54/// panel does not register `R`.
55pub fn navigation<R: Resource>(cx: &topcoat::context::Cx) -> Option<crate::NavigationItem> {
56    // No panel at all means no sidebar entry; `mounted()` falls back to `R::declare()` for loaders.
57    topcoat::context::try_app_context::<crate::resource::MountScope>(cx)?;
58    crate::resource::mounted::<R>(cx).map(|resource| resource.navigation.clone())
59}
60
61/// An admin panel: resources and pages under one prefix, framed by one shell
62/// and gated by one [`Auth`](crate::auth::Auth).
63///
64/// The app owns the router and mounts the panel into it with
65/// [`RouterBuilderPanelExt::panel`]:
66///
67/// ```text
68/// let router = Router::builder()
69///     .discover()
70///     .app_context(db)
71///     .panel(Panel::new("admin").resource::<UserResource>())?
72///     .build();
73/// ```
74///
75/// Registering is declarative: mounting builds each resource's [`ResourceDef`] and checks every
76/// slug, route and declaration before the panel serves anything.
77pub struct Panel {
78    prefix: String,
79    shell_assets: Option<ShellAssets>,
80    brand: Option<Brand>,
81    dark_mode: Option<bool>,
82    /// Frames the panel's pages; `None` renders the shipped shell.
83    layout: Option<LayoutRenderFn>,
84    /// The resources and pages, in registration order.
85    registrations: Vec<Box<dyn Registration>>,
86    /// `Content-Security-Policy: frame-ancestors …` for every response under the prefix; `None`
87    /// opts out, the default is `'self'`.
88    frame_ancestors: Option<String>,
89    /// Mistakes in the panel's own configuration, for mount to report.
90    configuration_errors: Vec<DeclarationError>,
91    /// Where file field bytes go; `None` stores the sanitized basename.
92    uploads: Option<crate::upload::InstalledUploader>,
93    /// App-owned filesystem directories served with hardening headers.
94    served_dirs: Vec<(String, PathBuf)>,
95    login_hint: Option<String>,
96    auth: crate::auth::Auth,
97}
98
99impl Panel {
100    /// Creates a `Panel` mounted at `prefix`, defaulting an empty prefix to `"/admin"`.
101    pub fn new(prefix: impl Into<String>) -> Self {
102        let raw = prefix.into();
103        let trimmed = raw.trim().trim_matches('/').to_string();
104        let prefix = if trimmed.is_empty() {
105            "/admin".to_string()
106        } else {
107            format!("/{trimmed}")
108        };
109        let configuration_errors = prefix
110            .trim_matches('/')
111            .split('/')
112            .filter_map(|segment| build::validate_route_segment("panel prefix", segment).err())
113            .map(DeclarationError::panel)
114            .collect();
115        Self {
116            prefix,
117            shell_assets: None,
118            brand: None,
119            dark_mode: None,
120            layout: None,
121            registrations: Vec::new(),
122            frame_ancestors: Some(headers::DEFAULT_FRAME_ANCESTORS.to_string()),
123            configuration_errors,
124            uploads: None,
125            served_dirs: Vec::new(),
126            login_hint: None,
127            auth: crate::auth::Auth::default(),
128        }
129    }
130
131    /// Returns the mount prefix, e.g. `"/admin"`.
132    pub fn prefix(&self) -> &str {
133        &self.prefix
134    }
135
136    /// Installs the [`Uploader`](crate::Uploader) this panel's file fields store through; without
137    /// one a file field stores the sanitized client filename.
138    pub fn uploads(mut self, uploader: impl crate::Uploader) -> Self {
139        self.uploads = Some(crate::upload::InstalledUploader::new(uploader));
140        self
141    }
142
143    /// Serves the directory `dir` at route pattern `path`, which must end in a catch-all and sits
144    /// outside the auth gate with hardening headers.
145    pub fn serve_dir(mut self, path: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
146        let path = path.into();
147        if !is_directory_pattern(&path) {
148            self.configuration_errors.push(DeclarationError::panel(
149                DeclarationErrorKind::ServeDirWithoutCatchAll { path: path.clone() },
150            ));
151        }
152        self.served_dirs.push((path, dir.into()));
153        self
154    }
155
156    /// Registers the stylesheet and font the default shell links, resolved through the router's
157    /// asset bundle.
158    pub fn shell_assets(mut self, stylesheet: Asset, font: Font) -> Self {
159        self.shell_assets = Some(ShellAssets { stylesheet, font });
160        self
161    }
162
163    /// Registers the [`Resource`] `R` at `{prefix}/{slug}` with its routes and sidebar entry, as
164    /// [`Resource::declare`] declares it.
165    pub fn resource<R: Resource>(self) -> Self {
166        self.resource_with::<R>(|def| def)
167    }
168
169    /// Registers the [`Resource`] `R` as [`resource`](Self::resource) does, with `customize`
170    /// adjusting its def for this panel only:
171    ///
172    /// ```text
173    /// Panel::new("portal").resource_with::<PostResource>(|def| def.policy(ReadOnly))
174    /// ```
175    pub fn resource_with<R: Resource>(
176        mut self,
177        customize: impl FnOnce(ResourceDef<R>) -> ResourceDef<R> + Send + 'static,
178    ) -> Self {
179        self.registrations
180            .push(Box::new(ResourceRegistration::<R>(Box::new(customize))));
181        self
182    }
183
184    /// Declares a [`Page`] at `{prefix}/{slug}` with its sidebar entry; pages share the resources'
185    /// slug namespace.
186    pub fn page<P: Page>(mut self) -> Self {
187        self.registrations.push(Box::new(PageRegistration::<P> {
188            home: false,
189            _page: std::marker::PhantomData,
190        }));
191        self
192    }
193
194    /// Declares the panel's home page at the panel prefix, replacing the redirect to the first
195    /// resource's list.
196    pub fn home<P: Page>(mut self) -> Self {
197        self.registrations.push(Box::new(PageRegistration::<P> {
198            home: true,
199            _page: std::marker::PhantomData,
200        }));
201        self
202    }
203
204    /// Frames the panel's pages with `render` instead of the shipped shell; `render` usually wraps
205    /// [`Panel::layout_shell`].
206    pub fn layout(mut self, render: LayoutRenderFn) -> Self {
207        self.layout = Some(render);
208        self
209    }
210
211    /// Sets branding for the shell (sidebar header, login card, and the topbar below `md`).
212    pub fn brand(mut self, brand: Brand) -> Self {
213        self.brand = Some(brand);
214        self
215    }
216
217    /// Sets the `frame-ancestors` directive the panel sends on every response under its prefix,
218    /// defaulting to `'self'`.
219    pub fn frame_ancestors(mut self, ancestors: impl Into<String>) -> Self {
220        self.frame_ancestors = Some(ancestors.into());
221        self
222    }
223
224    /// Sends no `frame-ancestors` directive for deployments whose proxy owns the whole CSP.
225    pub fn without_frame_ancestors(mut self) -> Self {
226        self.frame_ancestors = None;
227        self
228    }
229
230    /// Sets the theme a visitor who has not chosen one sees, dark when `true`.
231    pub fn dark_mode(mut self, enabled: bool) -> Self {
232        self.dark_mode = Some(enabled);
233        self
234    }
235
236    /// Configures this panel's authentication, defaulting to password auth; sessions belong to the
237    /// panel that signed them in.
238    pub fn auth(mut self, auth: crate::auth::Auth) -> Self {
239        self.auth = auth;
240        self
241    }
242
243    /// Renders a line under the login form for demo credentials or deployment hints.
244    pub fn login_hint(mut self, hint: impl Into<String>) -> Self {
245        self.login_hint = Some(hint.into());
246        self
247    }
248}
249
250/// What the panel serves at its prefix.
251enum Root {
252    /// A redirect to the first declared resource's list.
253    Redirect(String),
254    /// The [`Panel::home`] page.
255    Home,
256}
257
258#[cfg(test)]
259mod tests;