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