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;