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;
4pub(crate) mod area;
5mod audit;
6mod bar;
7mod build;
8pub(crate) mod commit_hook;
9mod detail;
10pub(crate) mod error_page;
11mod files;
12mod forms;
13pub(crate) mod gate;
14mod headers;
15mod host;
16mod impersonation;
17mod input;
18mod list;
19mod page_filters;
20pub(crate) mod pages;
21mod record_nav;
22mod record_page;
23mod register;
24mod relations;
25mod search;
26mod shell;
27pub(crate) mod state;
28#[cfg(test)]
29pub(crate) mod test_support;
30pub mod url;
31mod widgets;
32mod write;
33
34use topcoat::{asset::Asset, font::Font, router::LayoutRenderFn};
35
36pub use self::{
37    area::{Area, AreaGroup},
38    audit::AuditLog,
39    build::{PanelHandle, RouterBuilderPanelExt},
40    commit_hook::{ChangedField, ChangedRecord, CommitHook, PanelChange},
41    gate::{can_list, can_open, can_view_field},
42    impersonation::Impersonate,
43    list::{WiredTable, list_query, wired_table},
44    page_filters::page_filters,
45    pages::page_filter_bar,
46    shell::{Brand, Environment, Radius},
47    widgets::{record_table, stats, widgets},
48};
49pub(crate) use self::{bar::header_bar, pages::current_page};
50use self::{
51    register::{PageRegistration, Registration, ResourceRegistration},
52    shell::ShellAssets,
53};
54use crate::{
55    DeclarationError, DeclarationErrorKind, Page, PageDef,
56    resource::{Resource, ResourceDef},
57};
58
59/// `R`'s sidebar entry in the context's panel, with its label, URL and icon; `None` when the
60/// panel does not register `R`. A resource hidden from the sidebar still has one, to link to it by.
61pub fn navigation_item<R: Resource>(cx: &topcoat::context::Cx) -> Option<crate::NavigationItem> {
62    crate::resource::mounted::<R>(cx).map(|resource| resource.navigation.clone())
63}
64
65/// An admin panel: resources and pages under one prefix, framed by one shell
66/// and gated by one [`Auth`](crate::auth::Auth). Its pages carry a `robots` tag that asks search
67/// engines to neither index them nor follow their links.
68///
69/// The app owns the router and mounts the panel into it with
70/// [`RouterBuilderPanelExt::panel`]:
71///
72/// ```rust,no_run
73/// # use tablo_core::{NoForm, Panel, Resource, RouterBuilderPanelExt};
74/// # use topcoat::router::{Router, RouterBuilderDiscoverExt};
75/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
76/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
77/// # struct UserResource;
78/// # impl Resource for UserResource {
79/// #     type Model = User;
80/// #     type Form = NoForm<User>;
81/// # }
82/// # fn main() -> topcoat::Result<()> {
83/// # let db: toasty::Db = todo!();
84/// let router = Router::builder()
85///     .discover()
86///     .with(db)
87///     .panel(Panel::new("admin").resource::<UserResource>())?
88///     .build();
89/// # let _ = router;
90/// # Ok(())
91/// # }
92/// ```
93///
94/// Registering is declarative: mounting builds each resource's [`ResourceDef`] and checks every
95/// slug, route and declaration before the panel serves anything.
96///
97/// Besides its resources' and pages' routes, a panel at `/admin` serves:
98///
99/// - `GET /admin`: the [home page](Self::home), or a redirect to the first registered resource's
100///   list when there is none;
101/// - `GET`, `POST /admin/login` and `POST /admin/logout`, unless authentication is disabled, and
102///   `POST /admin/tenant`, the tenant switcher, when the panel also acts for tenants: it offers a
103///   tenant registration or settings page, or mounts a resource over tenant-owned rows;
104/// - `/admin/register`, `/admin/profile`, `/admin/forgot-password` and `/admin/reset-password` when
105///   [`Auth`](crate::auth::Auth) installs the feature each serves;
106/// - `POST /admin/{page}/-/actions/{action}` for a page's header actions, and
107///   `/admin/-/actions/{action}` for the home page's;
108/// - `GET /admin/-/search?q=`, the [global search](Self::without_global_search)'s results page,
109///   when the panel searches a resource;
110/// - every other path under `/admin`, with any method, as a 404 behind the auth gate, so a visitor
111///   signs in before learning a path serves nothing. The app's own routes under the prefix still
112///   take precedence.
113pub struct Panel {
114    prefix: String,
115    shell_assets: Option<ShellAssets>,
116    brand: Option<Brand>,
117    /// Whether [`Self::dark_by_default`] starts a visitor who has not chosen a theme dark.
118    dark_mode: bool,
119    /// The deployment [`Self::environment`] names on every page.
120    environment: Option<Environment>,
121    /// Frames the panel's pages; `None` renders the shipped shell.
122    layout: Option<LayoutRenderFn>,
123    /// The resources and pages, in registration order.
124    registrations: Vec<Box<dyn Registration>>,
125    /// The `Content-Security-Policy` of every response under the prefix.
126    content_security_policy: headers::Policy,
127    /// The sources [`content_security_policy_source`](Self::content_security_policy_source)
128    /// adds to the policy, as `(directive, source)`.
129    content_security_policy_sources: Vec<(String, String)>,
130    /// `Strict-Transport-Security`'s `max-age`, when the panel sends it.
131    strict_transport_security: Option<std::time::Duration>,
132    /// Mistakes in the panel's own configuration, for mount to report.
133    configuration_errors: Vec<DeclarationError>,
134    /// Where file field bytes go; `None` refuses to mount a form with a file field.
135    uploads: Option<crate::upload::InstalledUploader>,
136    /// App-owned filesystem directories served with hardening headers.
137    served_dirs: Vec<headers::ServedDir>,
138    login_hint: Option<crate::Text>,
139    /// The app's entries in the user menu, above Sign out.
140    user_menu: Vec<crate::NavigationItem>,
141    auth: crate::auth::AuthConfig,
142    /// The paths of the runtime endpoints that answer only this panel's sessions.
143    endpoints: Vec<String>,
144    /// Whether the topbar shows the bell over the user's stored notifications.
145    notifications: bool,
146    /// The zone a user without their own reads and enters wall-clock times in.
147    time_zone: jiff::tz::TimeZone,
148    /// The locales the panel offers, the default first.
149    locales: Vec<topcoat::l10n::Locale>,
150    /// The app's catalogs, by locale, over Tablo's.
151    translations: Vec<(topcoat::l10n::Locale, String)>,
152    /// Whether the topbar shows the global search box.
153    global_search: bool,
154    limits: crate::Limits,
155    /// The hooks every write of the panel runs before it commits, in registration order.
156    commit_hooks: Vec<commit_hook::CommitHookFn>,
157    /// How [`audit_log`](Self::audit_log) adjusts the [`AuditLog`] resource's def, when the panel
158    /// keeps one: it registers in the area group that places it, else in the panel.
159    audit_log: Option<audit::CustomizeAuditLog>,
160}
161
162impl Panel {
163    /// Creates a `Panel` mounted at `prefix`, defaulting an empty prefix to `"/admin"`.
164    pub fn new(prefix: impl Into<String>) -> Self {
165        let prefix = crate::naming::normalize_prefix(&prefix.into());
166        let configuration_errors = prefix
167            .trim_matches('/')
168            .split('/')
169            .filter_map(|segment| build::validate_route_segment("panel prefix", segment).err())
170            .map(DeclarationError::panel)
171            .collect();
172        Self {
173            prefix,
174            shell_assets: None,
175            brand: None,
176            dark_mode: false,
177            environment: None,
178            layout: None,
179            registrations: Vec::new(),
180            content_security_policy: headers::Policy::Default,
181            content_security_policy_sources: Vec::new(),
182            strict_transport_security: None,
183            configuration_errors,
184            uploads: None,
185            served_dirs: Vec::new(),
186            login_hint: None,
187            user_menu: Vec::new(),
188            auth: crate::auth::Auth::default().into_config(),
189            endpoints: Vec::new(),
190            notifications: false,
191            time_zone: jiff::tz::TimeZone::UTC,
192            locales: vec![crate::i18n::ENGLISH.clone()],
193            translations: Vec::new(),
194            global_search: true,
195            limits: crate::Limits::default(),
196            commit_hooks: Vec::new(),
197            audit_log: None,
198        }
199    }
200
201    /// Returns the mount prefix, e.g. `"/admin"`.
202    pub fn prefix(&self) -> &str {
203        &self.prefix
204    }
205
206    /// Registers the stylesheet and font the default shell links, resolved through the router's
207    /// asset bundle.
208    pub fn shell_assets(mut self, stylesheet: Asset, font: Font) -> Self {
209        self.shell_assets = Some(ShellAssets { stylesheet, font });
210        self
211    }
212
213    /// Registers the [`Resource`] `R` at `{prefix}/{slug}` with its routes and sidebar entry, as
214    /// [`Resource::declare`] declares it.
215    ///
216    /// A resource with the slug `users`, on a panel at `/admin`, serves:
217    ///
218    /// | Method | Path | Serves |
219    /// | --- | --- | --- |
220    /// | `GET` | `/admin/users` | the list |
221    /// | `GET` | `/admin/users/{id}` | the detail page; 404 when the resource's view is empty |
222    /// | `POST` | `/admin/users/{id}/delete` | delete one record |
223    /// | `POST` | `/admin/users/-/bulk-delete` | delete the selected records |
224    /// | `GET` | `/admin/users/-/export` | the list as CSV |
225    /// | `GET`, `POST` | `/admin/users/-/create` | the create form ¹ |
226    /// | `GET`, `POST` | `/admin/users/{id}/edit` | the edit form ¹ |
227    /// | `GET` | `/admin/users/-/options` | a searchable or dependent choice's options ¹ |
228    /// | `POST` | `/admin/users/{id}/-/actions/{action}` | a record action on one record ² |
229    /// | `POST` | `/admin/users/-/actions/{action}` | a header action, or a bulk action ³ |
230    /// | `GET` | `/admin/users/-/actions/{action}/options` | an action input's options ² ³ |
231    /// | `POST` | `/admin/users/{id}/-/relations/{relation}/-/actions/{action}` | attach, or detach the selection ⁴ |
232    /// | `POST` | `/admin/users/{id}/-/relations/{relation}/{related}/-/actions/{action}` | detach one record ⁴ |
233    /// | `GET` | `/admin/users/{id}/-/relations/{relation}/-/actions/{action}/options` | the attach dialog's options ⁴ |
234    ///
235    /// ¹ Only for a resource whose `Form` is a record form, not [`NoForm`](crate::NoForm).
236    /// ² Only when an action is placed on a row, the detail page or the edit page.
237    /// ³ Only when the resource has a header action, or an action placed on the bulk bar.
238    /// ⁴ Only when the resource has a [many-to-many relation](crate::Relation::belongs_to_many).
239    ///
240    /// `{id}` is the record's primary key. The `-` segment keeps the list's own routes apart from
241    /// a record's: a record whose key is `create` still has its detail page at
242    /// `/admin/users/create`. Each route checks the resource's policy.
243    pub fn resource<R: Resource>(self) -> Self {
244        self.resource_with::<R>(|def| def)
245    }
246
247    /// Registers the [`Resource`] `R` as [`resource`](Self::resource) does, with `customize`
248    /// adjusting its def for this panel only:
249    ///
250    /// ```rust
251    /// # use tablo_core::{NoForm, Panel, ReadOnly, Resource};
252    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
253    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
254    /// # struct PostResource;
255    /// # impl Resource for PostResource {
256    /// #     type Model = Post;
257    /// #     type Form = NoForm<Post>;
258    /// # }
259    /// Panel::new("portal").resource_with::<PostResource>(|def| def.policy(ReadOnly));
260    /// ```
261    pub fn resource_with<R: Resource>(
262        mut self,
263        customize: impl FnOnce(ResourceDef<R>) -> ResourceDef<R> + Send + 'static,
264    ) -> Self {
265        self.registrations
266            .push(Box::new(ResourceRegistration::<R>(Box::new(customize))));
267        self
268    }
269
270    /// Declares a [`Page`] at `{prefix}/{slug}` with its sidebar entry, as [`Page::declare`]
271    /// declares it; pages share the resources' slug namespace.
272    pub fn page<P: Page>(self) -> Self {
273        self.page_with::<P>(|def| def)
274    }
275
276    /// Declares the [`Page`] `P` as [`page`](Self::page) does, with `customize` adjusting its def
277    /// for this panel only:
278    ///
279    /// ```rust
280    /// # use tablo_core::{Page, Panel};
281    /// # use topcoat::{Result, context::Cx, view::View};
282    /// # struct ReportsPage;
283    /// # impl Page for ReportsPage {
284    /// #     async fn render(_cx: &Cx) -> Result<impl View> { Ok(()) }
285    /// # }
286    /// # fn is_manager(_cx: &Cx) -> bool { true }
287    /// Panel::new("portal").page_with::<ReportsPage>(|def| def.access(is_manager));
288    /// ```
289    pub fn page_with<P: Page>(
290        mut self,
291        customize: impl FnOnce(PageDef) -> PageDef + Send + 'static,
292    ) -> Self {
293        self.registrations.push(Box::new(PageRegistration::<P> {
294            home: false,
295            customize: Box::new(customize),
296            _page: std::marker::PhantomData,
297        }));
298        self
299    }
300
301    /// Registers `area`, a part of the panel under `{prefix}/{slug}` with a sidebar of its own,
302    /// after the resources and pages already registered: see [`Area`].
303    pub fn area(mut self, area: Area) -> Self {
304        self.registrations.push(Box::new(area));
305        self
306    }
307
308    /// Declares the panel's home page at the panel prefix, replacing the redirect to the first
309    /// resource's list. The page mounts with its def as [`Page::declare`] returns it.
310    pub fn home<P: Page>(mut self) -> Self {
311        self.registrations.push(Box::new(PageRegistration::<P> {
312            home: true,
313            customize: Box::new(|def| def),
314            _page: std::marker::PhantomData,
315        }));
316        self
317    }
318
319    /// Frames the panel's pages with `render` instead of the shipped shell; `render` usually wraps
320    /// [`Panel::layout_shell`].
321    ///
322    /// The panel registers its layout at its prefix itself, with or without this call: a
323    /// `#[layout]` of the app's own there would nest a second document inside the first.
324    pub fn layout(mut self, render: LayoutRenderFn) -> Self {
325        self.layout = Some(render);
326        self
327    }
328
329    /// Sets branding for the shell (sidebar header and the topbar below `md`) and for the sign-in
330    /// and sign-up pages, above their card.
331    pub fn brand(mut self, brand: Brand) -> Self {
332        self.configuration_errors.extend(
333            brand
334                .declaration_errors()
335                .into_iter()
336                .map(DeclarationError::panel),
337        );
338        self.brand = Some(brand);
339        self
340    }
341
342    /// Starts a visitor who has not chosen a theme dark; without the call the panel starts light.
343    pub fn dark_by_default(mut self) -> Self {
344        self.dark_mode = true;
345        self
346    }
347
348    /// Configures this panel's authentication, defaulting to password auth; sessions belong to the
349    /// panel that signed them in.
350    pub fn auth<U: crate::PanelUser>(mut self, auth: crate::auth::Auth<U>) -> Self {
351        self.auth = auth.into_config();
352        self
353    }
354
355    /// Declares a shard or procedure the app serves on this panel's pages: a request to it
356    /// answers as a request under the prefix does.
357    ///
358    /// Topcoat serves shards and procedures under its runtime path, outside every panel's
359    /// prefix. A request to an endpoint no panel declares runs as the panel whose session sent
360    /// it, whichever panel that is; a panel without sign-in has no session, so its request runs as
361    /// no panel, and [`scoped_query`](crate::scoped_query) and the [`url`](crate::url) helpers
362    /// find no resource. A declared one runs as this panel: a session another panel
363    /// signed in is no session here, so it answers 401 when the panel requires sign-in, and
364    /// [`auth::guard`](crate::auth::guard), [`auth::user`](crate::auth::user), the tenant and
365    /// [`can_list`] answer for this panel. Several panels may declare the same
366    /// endpoint; each of their sessions reaches it.
367    ///
368    /// The router still registers the endpoint, through discovery or `.route(..)`. A route
369    /// outside Topcoat's runtime path refuses to mount: the panel's prefix or the app's own
370    /// layers guard it.
371    ///
372    /// ```rust
373    /// # use tablo_core::{Panel, auth};
374    /// # use topcoat::{Result, context::Cx, runtime::shard, view::{View, view}};
375    /// #[shard]
376    /// async fn revenue(cx: &Cx) -> Result<impl View> {
377    ///     auth::guard(cx)?;
378    ///     Ok(view! { "…" })
379    /// }
380    ///
381    /// Panel::new("admin").endpoint(revenue);
382    /// ```
383    pub fn endpoint(mut self, endpoint: impl topcoat::router::Route) -> Self {
384        let path = endpoint.path().as_str().to_owned();
385        if !state::under_prefix(crate::topcoat_compat::RUNTIME_PREFIX, &path) {
386            self.configuration_errors.push(DeclarationError::panel(
387                DeclarationErrorKind::EndpointOutsideRuntime { path: path.clone() },
388            ));
389        }
390        self.endpoints.push(path);
391        self
392    }
393
394    /// Adds `item` to the user menu in the sidebar's footer, above Sign out: a profile or a
395    /// settings page. The item names its URL with
396    /// [`NavigationItem::at`](crate::NavigationItem::at); its badge shows in the sidebar only.
397    pub fn user_menu_item(mut self, item: crate::NavigationItem) -> Self {
398        self.user_menu.push(item);
399        self
400    }
401
402    /// Shows a bell in the topbar over the signed-in user's
403    /// [stored notifications](crate::Notifications): an unread count, and a sheet
404    /// that lists them newest first, to mark read or unread and remove. Once the page loads, the
405    /// bell keeps updating over Topcoat's runtime connection, and a notification sent while the
406    /// page is open also shows as a toast.
407    ///
408    /// The bell lists what was sent on this panel for the tenant the user acts for, and what was
409    /// sent for no tenant; its procedures mark and remove only those, so an id it does not list
410    /// changes nothing. An open bell checks the session again before it shows anything new, and
411    /// [`auth::revoke_sessions_for_user`](crate::auth::revoke_sessions_for_user) stops the user's
412    /// open bells at once. A send wakes the bells open on the process that sent it: behind a load
413    /// balancer, a bell on another process shows the notification on its next page load.
414    ///
415    /// The panel needs auth and the [`StoredNotification`] model, which
416    /// [`models!`](crate::models!) registers. Nothing deletes stored notifications on its own.
417    ///
418    /// ```rust
419    /// # use tablo_core::Panel;
420    /// Panel::new("admin").notifications();
421    /// ```
422    ///
423    /// [`StoredNotification`]: crate::notification::StoredNotification
424    pub fn notifications(mut self) -> Self {
425        self.notifications = true;
426        self
427    }
428
429    /// Sets the time zone a user whose [`PanelUser::time_zone`](crate::PanelUser::time_zone) names
430    /// none reads and enters wall-clock times in, and that an unsigned request uses: UTC by
431    /// default. See [`time_zone`](crate::time_zone).
432    ///
433    /// ```rust
434    /// # use tablo_core::Panel;
435    /// let zone = jiff::tz::TimeZone::get("Europe/Paris").expect("the zone database names it");
436    /// Panel::new("admin").time_zone(zone);
437    /// ```
438    pub fn time_zone(mut self, zone: jiff::tz::TimeZone) -> Self {
439        self.time_zone = zone;
440        self
441    }
442
443    /// Sets the locales the panel offers, the default first: `en` by default. Each request reads
444    /// one, which [`topcoat::l10n::locale()`] returns: the one the visitor picked from the shell's
445    /// language menu, else the signed-in user's [`PanelUser::locale`](crate::PanelUser::locale),
446    /// else the browser's best match, else the default. A locale matches the closest offered one
447    /// of its language and script, as `fr-CA` reads `fr` and `zh-HK` reads `zh-Hant`.
448    ///
449    /// Offering several shows the language menu in the sidebar footer, each locale named by an
450    /// attribute of the message `tablo-shell-language` and falling back to its tag; picking one
451    /// remembers it in a cookie under the panel's prefix, and on the signed-in user's account
452    /// through [`Authenticator::store_locale`](crate::Authenticator::store_locale).
453    ///
454    /// Tablo translates its own words into `en` and `fr`; a locale it does not translate needs the
455    /// app's catalog, from [`translations`](Self::translations). The pseudo-locale `en-XA` spells
456    /// every catalog's English with accented letters, so a word that reads plain on its page is one
457    /// no catalog spells.
458    ///
459    /// ```rust
460    /// # use tablo_core::Panel;
461    /// use topcoat::l10n::locale;
462    ///
463    /// Panel::new("admin").locales([locale!("fr"), locale!("en")]);
464    /// ```
465    ///
466    /// The panel refuses to mount an empty list, a repeated locale, and a locale no catalog
467    /// translates.
468    pub fn locales(mut self, locales: impl IntoIterator<Item = topcoat::l10n::Locale>) -> Self {
469        self.locales = locales.into_iter().collect();
470        self
471    }
472
473    /// Adds the app's Fluent catalog for `locale`: the messages its [`Text`](crate::Text)s name,
474    /// and any of Tablo's it spells otherwise.
475    ///
476    /// A message the request's locale leaves out reads from the default locale's catalogs, then
477    /// from English; an English locale reads English alone. A catalog for a language, such as `fr`,
478    /// serves its regions too: `fr-CA` reads it under its own.
479    ///
480    /// ```rust,ignore
481    /// Panel::new("admin")
482    ///     .locales([locale!("fr"), locale!("en")])
483    ///     .translations(locale!("fr"), include_str!("../locales/fr.ftl"))
484    ///     .translations(locale!("en"), include_str!("../locales/en.ftl"));
485    /// ```
486    ///
487    /// Tablo's messages name a tenant through the Fluent term `-tenant`, so an app that calls its
488    /// tenants teams overrides that one term in each of its catalogs rather than each message.
489    /// Each locale's term has the forms its messages read, listed atop Tablo's catalog for it;
490    /// the French one adds a `.gender` attribute the messages agree with:
491    ///
492    /// ```ftl
493    /// -tenant = { $form ->
494    ///    *[lower] team
495    ///     [upper] Team
496    ///     [upper-plural] Teams
497    ///     [indefinite] a team
498    /// }
499    /// ```
500    ///
501    /// The panel refuses to mount a catalog that is not valid Fluent, and one for a locale it does
502    /// not offer, `en` aside.
503    pub fn translations(
504        mut self,
505        locale: topcoat::l10n::Locale,
506        catalog: impl Into<String>,
507    ) -> Self {
508        self.translations.push((locale, catalog.into()));
509        self
510    }
511
512    /// Leaves out the global search the panel otherwise shows: the box in its topbar and its
513    /// results page.
514    ///
515    /// By default the topbar holds a search box over every resource the panel mounts, unless a
516    /// resource opts out with
517    /// [`ResourceDef::without_global_search`](crate::ResourceDef::without_global_search). As the
518    /// user types, it lists up to 5 results of each resource, headed by the resource's plural
519    /// label in the sidebar's order, and links the page at `{prefix}/-/search?q=`, which lists up
520    /// to 25 of each and which Enter opens, with or without JavaScript. Cmd+K, or Ctrl+K, focuses
521    /// the box from anywhere on the page. The term matches a record
522    /// when each of its first 8 distinct words does;
523    /// [`ResourceDef::global_search`](crate::ResourceDef::global_search) says what a word matches,
524    /// which records show and what each opens.
525    ///
526    /// A resource is not searched when it has no searchable column and no title column, or when no
527    /// result could open a page: its records have no URL, or it has neither a detail page nor a
528    /// form. The box shows to a request that may list one of the searched resources, and a panel
529    /// that searches none serves no results page.
530    ///
531    /// The box's results are a shard the panel declares as its [endpoint](Self::endpoint), so they
532    /// answer for a panel without sign-in too. Topcoat serves it at one path for every panel, and
533    /// runs it as the panel that signed the session in, else as the first such panel without
534    /// sign-in. On any other panel, such as the second of two without sign-in, the box lists no
535    /// results as the user types and links the results page alone.
536    ///
537    /// ```rust
538    /// # use tablo_core::Panel;
539    /// Panel::new("admin").without_global_search();
540    /// ```
541    pub fn without_global_search(mut self) -> Self {
542        self.global_search = false;
543        self
544    }
545
546    /// Renders a line under the sign-in card for demo credentials or deployment hints.
547    pub fn login_hint(mut self, hint: impl Into<crate::Text>) -> Self {
548        self.login_hint = Some(hint.into());
549        self
550    }
551
552    /// Sets the bounds the panel holds each request to: rows exported and imported, records one
553    /// bulk action runs on, and bytes a request body holds. [`Limits::default`](crate::Limits)
554    /// lists the defaults.
555    pub fn limits(mut self, limits: crate::Limits) -> Self {
556        self.limits = limits;
557        self
558    }
559}
560
561/// What the panel serves at its prefix.
562enum Root {
563    /// A redirect to the first declared resource's list that the sidebar shows.
564    Redirect(String),
565    /// The [`Panel::home`] page.
566    Home,
567}
568
569#[cfg(test)]
570mod tests;