Skip to main content

tablo_core/
panel.rs

1//! `Panel` — resources and pages under one prefix, which an app mounts into its router.
2
3mod actions;
4mod bar;
5mod build;
6mod detail;
7mod forms;
8pub(crate) mod gate;
9mod headers;
10mod list;
11mod pages;
12mod register;
13mod relations;
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
25pub(crate) use self::{
26    bar::header_bar, build::route_path, forms::parse_form_body, gate::panel_prefix,
27};
28use self::{
29    build::is_directory_pattern,
30    register::{PageRegistration, Registration, ResourceRegistration},
31    shell::ShellAssets,
32};
33pub use self::{
34    build::{PanelHandle, RouterBuilderPanelExt},
35    gate::{can_list, can_open},
36    shell::Brand,
37};
38use crate::{
39    DeclarationError, DeclarationErrorKind, Page, PageDef,
40    resource::{Resource, ResourceDef},
41};
42
43/// Returns the panel's list table for `R`, with the request's row actions wired on, for a page
44/// that owns its table; render it over the page's own query with
45/// [`WiredTable::render`](crate::table::WiredTable::render).
46///
47/// # Errors
48///
49/// A declaration error when the context's panel does not mount `R`.
50pub fn wired_table<R: Resource>(
51    cx: &topcoat::context::Cx,
52) -> topcoat::Result<crate::table::WiredTable<R::Model>> {
53    let resource = crate::resource::require_mounted::<R>(cx)?;
54    Ok(self::list::wire_table_actions(cx, &resource))
55}
56
57/// `R`'s sidebar entry in the context's panel, with its label, URL and icon; `None` when the
58/// panel does not register `R`.
59pub fn navigation<R: Resource>(cx: &topcoat::context::Cx) -> Option<crate::NavigationItem> {
60    crate::resource::mounted::<R>(cx).map(|resource| resource.navigation.clone())
61}
62
63/// The prefix [`Panel::new`] mounts `raw` at: trimmed, one leading slash, no trailing slash, and
64/// `/admin` when nothing is left.
65pub(crate) fn normalize_prefix(raw: &str) -> String {
66    let trimmed = raw.trim().trim_matches('/');
67    if trimmed.is_empty() {
68        "/admin".to_string()
69    } else {
70        format!("/{trimmed}")
71    }
72}
73
74/// An admin panel: resources and pages under one prefix, framed by one shell
75/// and gated by one [`Auth`](crate::auth::Auth).
76///
77/// The app owns the router and mounts the panel into it with
78/// [`RouterBuilderPanelExt::panel`]:
79///
80/// ```rust,no_run
81/// # use tablo_core::{NoForm, Panel, Resource, RouterBuilderPanelExt};
82/// # use topcoat::router::{Router, RouterBuilderDiscoverExt};
83/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
84/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
85/// # struct UserResource;
86/// # impl Resource for UserResource {
87/// #     type Model = User;
88/// #     type Form = NoForm<User>;
89/// # }
90/// # fn main() -> topcoat::Result<()> {
91/// # let db: toasty::Db = todo!();
92/// let router = Router::builder()
93///     .discover()
94///     .app_context(db)
95///     .panel(Panel::new("admin").resource::<UserResource>())?
96///     .build();
97/// # let _ = router;
98/// # Ok(())
99/// # }
100/// ```
101///
102/// Registering is declarative: mounting builds each resource's [`ResourceDef`] and checks every
103/// slug, route and declaration before the panel serves anything.
104pub struct Panel {
105    prefix: String,
106    shell_assets: Option<ShellAssets>,
107    brand: Option<Brand>,
108    dark_mode: Option<bool>,
109    /// Frames the panel's pages; `None` renders the shipped shell.
110    layout: Option<LayoutRenderFn>,
111    /// The resources and pages, in registration order.
112    registrations: Vec<Box<dyn Registration>>,
113    /// `Content-Security-Policy: frame-ancestors …` for every response under the prefix; `None`
114    /// opts out, the default is `'self'`.
115    frame_ancestors: Option<String>,
116    /// Mistakes in the panel's own configuration, for mount to report.
117    configuration_errors: Vec<DeclarationError>,
118    /// Where file field bytes go; `None` refuses to mount a form with a file field.
119    uploads: Option<crate::upload::InstalledUploader>,
120    /// App-owned filesystem directories served with hardening headers.
121    served_dirs: Vec<(String, PathBuf)>,
122    login_hint: Option<String>,
123    auth: crate::auth::Auth,
124}
125
126impl Panel {
127    /// Creates a `Panel` mounted at `prefix`, defaulting an empty prefix to `"/admin"`.
128    pub fn new(prefix: impl Into<String>) -> Self {
129        let prefix = normalize_prefix(&prefix.into());
130        let configuration_errors = prefix
131            .trim_matches('/')
132            .split('/')
133            .filter_map(|segment| build::validate_route_segment("panel prefix", segment).err())
134            .map(DeclarationError::panel)
135            .collect();
136        Self {
137            prefix,
138            shell_assets: None,
139            brand: None,
140            dark_mode: None,
141            layout: None,
142            registrations: Vec::new(),
143            frame_ancestors: Some(headers::DEFAULT_FRAME_ANCESTORS.to_string()),
144            configuration_errors,
145            uploads: None,
146            served_dirs: Vec::new(),
147            login_hint: None,
148            auth: crate::auth::Auth::default(),
149        }
150    }
151
152    /// Returns the mount prefix, e.g. `"/admin"`.
153    pub fn prefix(&self) -> &str {
154        &self.prefix
155    }
156
157    /// Installs the [`Uploader`](crate::Uploader) this panel's file fields store through; without
158    /// one a panel whose resource form declares a file field refuses to mount.
159    pub fn uploads(mut self, uploader: impl crate::Uploader) -> Self {
160        self.uploads = Some(crate::upload::InstalledUploader::new(uploader));
161        self
162    }
163
164    /// Stores file fields' uploads in `dir` and serves them at `url_prefix` (`"/uploads"`): a
165    /// [`DirUploader`](crate::upload::DirUploader) and [`serve_dir`](Self::serve_dir) over the
166    /// same directory.
167    ///
168    /// ```no_run
169    /// # use tablo_core::Panel;
170    /// Panel::new("admin").uploads_dir("/uploads", "var/uploads");
171    /// ```
172    pub fn uploads_dir(self, url_prefix: &str, dir: impl Into<PathBuf>) -> Self {
173        let url_prefix = url_prefix.trim_end_matches('/');
174        let dir = dir.into();
175        self.serve_dir(format!("{url_prefix}/{{*file}}"), dir.clone())
176            .uploads(crate::upload::DirUploader::new(url_prefix, dir))
177    }
178
179    /// Serves the directory `dir` at route pattern `path`, which must end in a catch-all and
180    /// carries hardening headers. The auth gate does not cover it, unless `path` sits under the
181    /// panel prefix, which the gate covers.
182    pub fn serve_dir(mut self, path: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
183        let path = path.into();
184        if !is_directory_pattern(&path) {
185            self.configuration_errors.push(DeclarationError::panel(
186                DeclarationErrorKind::ServeDirWithoutCatchAll { path: path.clone() },
187            ));
188        }
189        self.served_dirs.push((path, dir.into()));
190        self
191    }
192
193    /// Registers the stylesheet and font the default shell links, resolved through the router's
194    /// asset bundle.
195    pub fn shell_assets(mut self, stylesheet: Asset, font: Font) -> Self {
196        self.shell_assets = Some(ShellAssets { stylesheet, font });
197        self
198    }
199
200    /// Registers the [`Resource`] `R` at `{prefix}/{slug}` with its routes and sidebar entry, as
201    /// [`Resource::declare`] declares it.
202    pub fn resource<R: Resource>(self) -> Self {
203        self.resource_with::<R>(|def| def)
204    }
205
206    /// Registers the [`Resource`] `R` as [`resource`](Self::resource) does, with `customize`
207    /// adjusting its def for this panel only:
208    ///
209    /// ```rust
210    /// # use tablo_core::{NoForm, Panel, ReadOnly, Resource};
211    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
212    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
213    /// # struct PostResource;
214    /// # impl Resource for PostResource {
215    /// #     type Model = Post;
216    /// #     type Form = NoForm<Post>;
217    /// # }
218    /// Panel::new("portal").resource_with::<PostResource>(|def| def.policy(ReadOnly));
219    /// ```
220    pub fn resource_with<R: Resource>(
221        mut self,
222        customize: impl FnOnce(ResourceDef<R>) -> ResourceDef<R> + Send + 'static,
223    ) -> Self {
224        self.registrations
225            .push(Box::new(ResourceRegistration::<R>(Box::new(customize))));
226        self
227    }
228
229    /// Declares a [`Page`] at `{prefix}/{slug}` with its sidebar entry, as [`Page::declare`]
230    /// declares it; pages share the resources' slug namespace.
231    pub fn page<P: Page>(self) -> Self {
232        self.page_with::<P>(|def| def)
233    }
234
235    /// Declares the [`Page`] `P` as [`page`](Self::page) does, with `customize` adjusting its def
236    /// for this panel only:
237    ///
238    /// ```rust
239    /// # use tablo_core::{Page, Panel};
240    /// # use topcoat::{Result, context::Cx, view::View};
241    /// # struct ReportsPage;
242    /// # impl Page for ReportsPage {
243    /// #     async fn render(_cx: &Cx) -> Result<impl View> { Ok(()) }
244    /// # }
245    /// # fn is_manager(_cx: &Cx) -> bool { true }
246    /// Panel::new("portal").page_with::<ReportsPage>(|def| def.access(is_manager));
247    /// ```
248    pub fn page_with<P: Page>(
249        mut self,
250        customize: impl FnOnce(PageDef) -> PageDef + Send + 'static,
251    ) -> Self {
252        self.registrations.push(Box::new(PageRegistration::<P> {
253            home: false,
254            customize: Box::new(customize),
255            _page: std::marker::PhantomData,
256        }));
257        self
258    }
259
260    /// Declares the panel's home page at the panel prefix, replacing the redirect to the first
261    /// resource's list. The page mounts with its def as [`Page::declare`] returns it.
262    pub fn home<P: Page>(mut self) -> Self {
263        self.registrations.push(Box::new(PageRegistration::<P> {
264            home: true,
265            customize: Box::new(|def| def),
266            _page: std::marker::PhantomData,
267        }));
268        self
269    }
270
271    /// Frames the panel's pages with `render` instead of the shipped shell; `render` usually wraps
272    /// [`Panel::layout_shell`].
273    pub fn layout(mut self, render: LayoutRenderFn) -> Self {
274        self.layout = Some(render);
275        self
276    }
277
278    /// Sets branding for the shell (sidebar header, login card, and the topbar below `md`).
279    pub fn brand(mut self, brand: Brand) -> Self {
280        self.brand = Some(brand);
281        self
282    }
283
284    /// Sets the `frame-ancestors` directive the panel sends on every response under its prefix,
285    /// defaulting to `'self'`.
286    pub fn frame_ancestors(mut self, ancestors: impl Into<String>) -> Self {
287        self.frame_ancestors = Some(ancestors.into());
288        self
289    }
290
291    /// Sends no `frame-ancestors` directive for deployments whose proxy owns the whole CSP.
292    pub fn without_frame_ancestors(mut self) -> Self {
293        self.frame_ancestors = None;
294        self
295    }
296
297    /// Sets the theme a visitor who has not chosen one sees, dark when `true`.
298    pub fn dark_mode(mut self, enabled: bool) -> Self {
299        self.dark_mode = Some(enabled);
300        self
301    }
302
303    /// Configures this panel's authentication, defaulting to password auth; sessions belong to the
304    /// panel that signed them in.
305    pub fn auth(mut self, auth: crate::auth::Auth) -> Self {
306        self.auth = auth;
307        self
308    }
309
310    /// Renders a line under the login form for demo credentials or deployment hints.
311    pub fn login_hint(mut self, hint: impl Into<String>) -> Self {
312        self.login_hint = Some(hint.into());
313        self
314    }
315}
316
317/// What the panel serves at its prefix.
318enum Root {
319    /// A redirect to the first declared resource's list.
320    Redirect(String),
321    /// The [`Panel::home`] page.
322    Home,
323}
324
325#[cfg(test)]
326mod tests;