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;