Skip to main content

tablo_core/
panel.rs

1//! `Panel` — an admin panel 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,
36    shell::Brand,
37};
38use crate::{
39    DeclarationError, DeclarationErrorKind, Page,
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)]
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` stores the sanitized basename.
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    /// Serves the directory `dir` at route pattern `path`, which must end in a catch-all and sits
165    /// outside the auth gate with hardening headers.
166    pub fn serve_dir(mut self, path: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
167        let path = path.into();
168        if !is_directory_pattern(&path) {
169            self.configuration_errors.push(DeclarationError::panel(
170                DeclarationErrorKind::ServeDirWithoutCatchAll { path: path.clone() },
171            ));
172        }
173        self.served_dirs.push((path, dir.into()));
174        self
175    }
176
177    /// Registers the stylesheet and font the default shell links, resolved through the router's
178    /// asset bundle.
179    pub fn shell_assets(mut self, stylesheet: Asset, font: Font) -> Self {
180        self.shell_assets = Some(ShellAssets { stylesheet, font });
181        self
182    }
183
184    /// Registers the [`Resource`] `R` at `{prefix}/{slug}` with its routes and sidebar entry, as
185    /// [`Resource::declare`] declares it.
186    pub fn resource<R: Resource>(self) -> Self {
187        self.resource_with::<R>(|def| def)
188    }
189
190    /// Registers the [`Resource`] `R` as [`resource`](Self::resource) does, with `customize`
191    /// adjusting its def for this panel only:
192    ///
193    /// ```rust
194    /// # use tablo_core::{NoForm, Panel, ReadOnly, Resource};
195    /// # #[derive(Debug, Clone, toasty::Model)]
196    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
197    /// # struct PostResource;
198    /// # impl Resource for PostResource {
199    /// #     type Model = Post;
200    /// #     type Form = NoForm<Post>;
201    /// # }
202    /// Panel::new("portal").resource_with::<PostResource>(|def| def.policy(ReadOnly));
203    /// ```
204    pub fn resource_with<R: Resource>(
205        mut self,
206        customize: impl FnOnce(ResourceDef<R>) -> ResourceDef<R> + Send + 'static,
207    ) -> Self {
208        self.registrations
209            .push(Box::new(ResourceRegistration::<R>(Box::new(customize))));
210        self
211    }
212
213    /// Declares a [`Page`] at `{prefix}/{slug}` with its sidebar entry; pages share the resources'
214    /// slug namespace.
215    pub fn page<P: Page>(mut self) -> Self {
216        self.registrations.push(Box::new(PageRegistration::<P> {
217            home: false,
218            _page: std::marker::PhantomData,
219        }));
220        self
221    }
222
223    /// Declares the panel's home page at the panel prefix, replacing the redirect to the first
224    /// resource's list.
225    pub fn home<P: Page>(mut self) -> Self {
226        self.registrations.push(Box::new(PageRegistration::<P> {
227            home: true,
228            _page: std::marker::PhantomData,
229        }));
230        self
231    }
232
233    /// Frames the panel's pages with `render` instead of the shipped shell; `render` usually wraps
234    /// [`Panel::layout_shell`].
235    pub fn layout(mut self, render: LayoutRenderFn) -> Self {
236        self.layout = Some(render);
237        self
238    }
239
240    /// Sets branding for the shell (sidebar header, login card, and the topbar below `md`).
241    pub fn brand(mut self, brand: Brand) -> Self {
242        self.brand = Some(brand);
243        self
244    }
245
246    /// Sets the `frame-ancestors` directive the panel sends on every response under its prefix,
247    /// defaulting to `'self'`.
248    pub fn frame_ancestors(mut self, ancestors: impl Into<String>) -> Self {
249        self.frame_ancestors = Some(ancestors.into());
250        self
251    }
252
253    /// Sends no `frame-ancestors` directive for deployments whose proxy owns the whole CSP.
254    pub fn without_frame_ancestors(mut self) -> Self {
255        self.frame_ancestors = None;
256        self
257    }
258
259    /// Sets the theme a visitor who has not chosen one sees, dark when `true`.
260    pub fn dark_mode(mut self, enabled: bool) -> Self {
261        self.dark_mode = Some(enabled);
262        self
263    }
264
265    /// Configures this panel's authentication, defaulting to password auth; sessions belong to the
266    /// panel that signed them in.
267    pub fn auth(mut self, auth: crate::auth::Auth) -> Self {
268        self.auth = auth;
269        self
270    }
271
272    /// Renders a line under the login form for demo credentials or deployment hints.
273    pub fn login_hint(mut self, hint: impl Into<String>) -> Self {
274        self.login_hint = Some(hint.into());
275        self
276    }
277}
278
279/// What the panel serves at its prefix.
280enum Root {
281    /// A redirect to the first declared resource's list.
282    Redirect(String),
283    /// The [`Panel::home`] page.
284    Home,
285}
286
287#[cfg(test)]
288mod tests;