pub struct Panel { /* private fields */ }Expand description
An admin panel: resources and pages under one prefix, framed by one shell
and gated by one Auth. Its pages carry a robots tag that asks search
engines to neither index them nor follow their links.
The app owns the router and mounts the panel into it with
RouterBuilderPanelExt::panel:
let router = Router::builder()
.discover()
.with(db)
.panel(Panel::new("admin").resource::<UserResource>())?
.build();Registering is declarative: mounting builds each resource’s ResourceDef and checks every
slug, route and declaration before the panel serves anything.
Besides its resources’ and pages’ routes, a panel at /admin serves:
GET /admin: the home page, or a redirect to the first registered resource’s list when there is none;GET,POST /admin/loginandPOST /admin/logout, unless authentication is disabled, andPOST /admin/tenant, the tenant switcher, when the panel also acts for tenants: it offers a tenant registration or settings page, or mounts a resource over tenant-owned rows;/admin/register,/admin/profile,/admin/forgot-passwordand/admin/reset-passwordwhenAuthinstalls the feature each serves;POST /admin/{page}/-/actions/{action}for a page’s header actions, and/admin/-/actions/{action}for the home page’s;GET /admin/-/search?q=, the global search’s results page, when the panel searches a resource;- every other path under
/admin, with any method, as a 404 behind the auth gate, so a visitor signs in before learning a path serves nothing. The app’s own routes under the prefix still take precedence.
Implementations§
Source§impl Panel
impl Panel
Sourcepub fn audit_log(
self,
customize: impl FnOnce(ResourceDef<AuditLog>) -> ResourceDef<AuditLog> + Send + 'static,
) -> Self
pub fn audit_log( self, customize: impl FnOnce(ResourceDef<AuditLog>) -> ResourceDef<AuditLog> + Send + 'static, ) -> Self
Keeps an AuditEntry for each record every write of the panel names, inside the write’s
transaction, and lists them through the AuditLog resource at {prefix}/audit-log, or
{prefix}/{area}/audit-log once AreaGroup::audit_log
places it.
Each entry names the signed-in user, the tenant they act for, the resource and record,
and the fields the write changed, as a CommitHook reads them: a
field marked #[columns(skip)], such as a password hash, is never stored. A write whose
entry fails to store rolls back with it.
A reader sees the entries of the resources whose list they may open, whatever row scope
each resource’s own query keeps, so the log’s policy should be no wider than the narrowest
of them. A delete keeps every field the record held until
prune_audit_log removes the entry.
The log is denied to everyone until customize gives it a policy, and that policy allows
reads only:
Panel::new("admin").audit_log(|log| log.policy(when(is_admin)));The database registers AuditEntry: models! does.
Calling audit_log again replaces customize.
Source§impl Panel
impl Panel
Sourcepub fn handle(self, db: &Db) -> Result<PanelHandle>
pub fn handle(self, db: &Db) -> Result<PanelHandle>
The panel’s resources declared and checked against db without a router: what a test, or
a job that runs with no router, builds its contexts from with PanelHandle::context.
A job that runs beside a router takes the mounted panel’s handle from
panel_handle instead.
§Errors
The declaration errors panel refuses the resources
with.
Source§impl Panel
impl Panel
Sourcepub fn before_commit<H: CommitHook>(self) -> Self
pub fn before_commit<H: CommitHook>(self) -> Self
Runs the CommitHook H inside the transaction of every write of the panel, after the
writing resource’s own Resource::before_commit. Several hooks run in the order they
are registered.
Source§impl Panel
impl Panel
Sourcepub fn uploads(self, uploader: impl Uploader) -> Self
pub fn uploads(self, uploader: impl Uploader) -> Self
Installs the Uploader this panel’s file fields store through; without
one a panel whose resource form declares a file field refuses to mount.
Sourcepub fn uploads_dir(self, url_prefix: &str, dir: impl Into<PathBuf>) -> Self
pub fn uploads_dir(self, url_prefix: &str, dir: impl Into<PathBuf>) -> Self
Stores file fields’ uploads in dir and serves them at url_prefix ("/uploads"), through
a DirUploader.
Panel::new("admin").uploads_dir("/uploads", "var/uploads");The directory answers only at a signed URL that expires, so a file’s bare URL opens
nothing and a leaked link stops working within two hours. The panel signs a file field’s
link, a FileColumn’s and an ImageColumn’s
thumbnail when it renders them, on a page whose tenant and policy let the user see the
record; a page of the app’s own signs a stored path with url::file.
The key derives from the router’s cookie Key, else from one drawn per process, so
without a shared Key a link opens only on the instance that rendered it. Any other
request answers 404. The files carry serve_dir’s hardening headers.
A file a record form stored for a submission that never saved is deleted after 24 hours:
DirUploader::expire_pending.
Sourcepub fn uploads_dir_with<U: Uploader>(
self,
url_prefix: &str,
dir: impl Into<PathBuf>,
wrap: impl FnOnce(DirUploader) -> U,
) -> Self
pub fn uploads_dir_with<U: Uploader>( self, url_prefix: &str, dir: impl Into<PathBuf>, wrap: impl FnOnce(DirUploader) -> U, ) -> Self
uploads_dir, with the DirUploader
wrapped in an app’s own Uploader, such as one that checks the bytes
before it stores them: the files still answer only at signed URLs, and pending ones still
expire. The wrapper forwards keep, and
delete to delete what a write lets go.
Sourcepub fn serve_dir(self, path: impl Into<String>, dir: impl Into<PathBuf>) -> Self
pub fn serve_dir(self, path: impl Into<String>, dir: impl Into<PathBuf>) -> Self
Serves the directory dir at route pattern path, which must end in a catch-all.
The files share the panel’s origin, so every file response carries hardening headers:
X-Content-Type-Options: nosniff;- a sandboxing
Content-Security-Policy, which is fixed; Content-Disposition: attachment, unless the file is a common image, audio or video type or plain text, so an uploaded HTML or SVG file downloads instead of running script.
Serve active documents from another origin. The directory is public: the auth gate does
not cover it, unless path sits under the panel prefix, which the gate covers. Serve
uploads with uploads_dir instead, whose URLs expire.
Source§impl Panel
impl Panel
Sourcepub const DEFAULT_CONTENT_SECURITY_POLICY: &str = DEFAULT_CSP
pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = DEFAULT_CSP
The Content-Security-Policy a panel sends unless
content_security_policy replaces it:
default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;
font-src 'self' data: https:; media-src 'self' data: blob: https:; object-src 'none';
base-uri 'self'; form-action 'self'; frame-ancestors 'self'- Script files load from the panel’s origin only, and
fetchand sockets reach it alone. Inline scripts run, so the policy does not stop a script an XSS injects, nor its sending data away through an image or a navigation; it stops framing,<base>hijacking, forms posting off-site and plugins. Inline scripts run: Topcoat streams a page’s live regions, such as a list’s rows, with inline scripts that carry no nonce, so a policy without'unsafe-inline'leaves the list loading on a page load.'unsafe-eval'lets Topcoat’s runtime compile its client expressions: without it the runtime throws at the page’s first binding and does not start. The script that marks a page’s panel is inline too. - Images, fonts and media may come from any
https:host, so an uploader or a font on a CDN keeps working. - Plugins are off,
<base>and form targets stay on the origin, and only the panel may frame itself.
While topcoat dev runs the app, the policy also lets the dev server’s origin load its
reload script and connect.
Sourcepub fn content_security_policy(self, policy: impl Into<String>) -> Self
pub fn content_security_policy(self, policy: impl Into<String>) -> Self
Replaces the panel’s Content-Security-Policy,
DEFAULT_CONTENT_SECURITY_POLICY by default.
Every response under the prefix carries the policy, and with it X-Content-Type-Options: nosniff, Referrer-Policy: same-origin and Cache-Control: no-store, so a page a
signed-out user saw does not come back from the browser’s cache. A header on the Response
a handler returns is kept; one it queues through Topcoat’s response_headers is added
after the panel’s, so both reach the browser, which enforces both policies.
- The headers cover the prefix’s 404 and 405 pages too; the app’s own routes outside the prefix carry whatever the app sets.
- The router answers three errors before any layer runs, so they carry none of them: the 403
for a cross-site request, the 400 for a malformed
x-topcoat-identityheader, and the 500 for a panic.
A policy with a line break refuses to mount.
Panel::new("admin").content_security_policy(Panel::DEFAULT_CONTENT_SECURITY_POLICY.replace(
"frame-ancestors 'self'",
"frame-ancestors 'self' https://intranet.example",
));Sourcepub fn content_security_policy_source(
self,
directive: impl Into<String>,
source: impl Into<String>,
) -> Self
pub fn content_security_policy_source( self, directive: impl Into<String>, source: impl Into<String>, ) -> Self
Adds source to directive of the panel’s policy, the default or the one
content_security_policy sets, keeping what topcoat dev adds: a form that posts to a payment provider adds its origin to form-action.
Panel::new("app").content_security_policy_source("form-action", "https://checkout.stripe.com");A fetch directive (*-src) the policy leaves out starts from default-src’s sources, so
it allows what it allowed before, plus source. Mounting refuses a directive that is not a
lowercase name, a source that is empty or holds whitespace, ; or ,, any other directive
the policy leaves out, and any source on a panel
without a policy.
Sourcepub fn without_content_security_policy(self) -> Self
pub fn without_content_security_policy(self) -> Self
Sends no Content-Security-Policy, for deployments whose proxy owns it. The panel’s other
headers stay.
Sourcepub fn strict_transport_security(self, max_age: Duration) -> Self
pub fn strict_transport_security(self, max_age: Duration) -> Self
Sends Strict-Transport-Security: max-age=<seconds> on every response under the prefix,
so a browser that reached the panel over HTTPS never tries plain HTTP for the host again
until max_age passes. Off by default, since TLS usually ends at a proxy, which may own
the header; turn it on only for a host that serves HTTPS alone.
Panel::new("admin").strict_transport_security(std::time::Duration::from_secs(31_536_000));Source§impl Panel
impl Panel
Sourcepub async fn document<'a>(
cx: &'a Cx,
title: impl Into<String>,
body: impl View + 'a,
) -> Result<BoxView<'a>>
pub async fn document<'a>( cx: &'a Cx, title: impl Into<String>, body: impl View + 'a, ) -> Result<BoxView<'a>>
Renders a complete HTML document around a page, with the shell settings of the panel the
request is under. A page outside every prefix belongs to no panel, so it renders without
them; such a page renders through PanelHandle::document.
Source§impl Panel
impl Panel
Sourcepub fn environment(self, environment: Environment) -> Self
pub fn environment(self, environment: Environment) -> Self
Names the deployment the panel runs in on every page: a stripe and a badge, and the name at the start of the browser tab title. Production usually names none.
Source§impl Panel
impl Panel
Sourcepub async fn render_shell<'a>(
cx: &'a Cx,
nav_items: &[NavigationItem],
current_path: &str,
slot: Child<'a>,
extra_class: Option<String>,
) -> Result<BoxView<'a>>
pub async fn render_shell<'a>( cx: &'a Cx, nav_items: &[NavigationItem], current_path: &str, slot: Child<'a>, extra_class: Option<String>, ) -> Result<BoxView<'a>>
Renders the shell around slot with an optional outer class.
Sourcepub fn layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> BoxView<'a>
pub fn layout_shell<'a>(cx: &'a Cx, slot: Slot<'a>) -> BoxView<'a>
The panel shell around a page, as a complete HTML document: the layout
every panel registers at its prefix unless Panel::layout replaces
it, and what a replacement calls to keep the shell around its own
markup.
The stylesheet and font are supplied to the Panel builder with
Self::shell_assets. A Panel without those values remains renderable
for tests and custom document owners, but does not pretend that a CSS
bundle exists. Errors from the page slot propagate unchanged when the
document view is resolved.
Source§impl Panel
impl Panel
Sourcepub fn new(prefix: impl Into<String>) -> Self
pub fn new(prefix: impl Into<String>) -> Self
Creates a Panel mounted at prefix, defaulting an empty prefix to "/admin".
Sourcepub fn shell_assets(self, stylesheet: Asset, font: Font) -> Self
pub fn shell_assets(self, stylesheet: Asset, font: Font) -> Self
Registers the stylesheet and font the default shell links, resolved through the router’s asset bundle.
Sourcepub fn resource<R: Resource>(self) -> Self
pub fn resource<R: Resource>(self) -> Self
Registers the Resource R at {prefix}/{slug} with its routes and sidebar entry, as
Resource::declare declares it.
A resource with the slug users, on a panel at /admin, serves:
| Method | Path | Serves |
|---|---|---|
GET | /admin/users | the list |
GET | /admin/users/{id} | the detail page; 404 when the resource’s view is empty |
POST | /admin/users/{id}/delete | delete one record |
POST | /admin/users/-/bulk-delete | delete the selected records |
GET | /admin/users/-/export | the list as CSV |
GET, POST | /admin/users/-/create | the create form ¹ |
GET, POST | /admin/users/{id}/edit | the edit form ¹ |
GET | /admin/users/-/options | a searchable or dependent choice’s options ¹ |
POST | /admin/users/{id}/-/actions/{action} | a record action on one record ² |
POST | /admin/users/-/actions/{action} | a header action, or a bulk action ³ |
GET | /admin/users/-/actions/{action}/options | an action input’s options ² ³ |
POST | /admin/users/{id}/-/relations/{relation}/-/actions/{action} | attach, or detach the selection ⁴ |
POST | /admin/users/{id}/-/relations/{relation}/{related}/-/actions/{action} | detach one record ⁴ |
GET | /admin/users/{id}/-/relations/{relation}/-/actions/{action}/options | the attach dialog’s options ⁴ |
¹ Only for a resource whose Form is a record form, not NoForm.
² Only when an action is placed on a row, the detail page or the edit page.
³ Only when the resource has a header action, or an action placed on the bulk bar.
⁴ Only when the resource has a many-to-many relation.
{id} is the record’s primary key. The - segment keeps the list’s own routes apart from
a record’s: a record whose key is create still has its detail page at
/admin/users/create. Each route checks the resource’s policy.
Sourcepub fn resource_with<R: Resource>(
self,
customize: impl FnOnce(ResourceDef<R>) -> ResourceDef<R> + Send + 'static,
) -> Self
pub fn resource_with<R: Resource>( self, customize: impl FnOnce(ResourceDef<R>) -> ResourceDef<R> + Send + 'static, ) -> Self
Sourcepub fn page<P: Page>(self) -> Self
pub fn page<P: Page>(self) -> Self
Declares a Page at {prefix}/{slug} with its sidebar entry, as Page::declare
declares it; pages share the resources’ slug namespace.
Sourcepub fn page_with<P: Page>(
self,
customize: impl FnOnce(PageDef) -> PageDef + Send + 'static,
) -> Self
pub fn page_with<P: Page>( self, customize: impl FnOnce(PageDef) -> PageDef + Send + 'static, ) -> Self
Sourcepub fn area(self, area: Area) -> Self
pub fn area(self, area: Area) -> Self
Registers area, a part of the panel under {prefix}/{slug} with a sidebar of its own,
after the resources and pages already registered: see Area.
Sourcepub fn home<P: Page>(self) -> Self
pub fn home<P: Page>(self) -> Self
Declares the panel’s home page at the panel prefix, replacing the redirect to the first
resource’s list. The page mounts with its def as Page::declare returns it.
Sourcepub fn layout(self, render: LayoutRenderFn) -> Self
pub fn layout(self, render: LayoutRenderFn) -> Self
Frames the panel’s pages with render instead of the shipped shell; render usually wraps
Panel::layout_shell.
The panel registers its layout at its prefix itself, with or without this call: a
#[layout] of the app’s own there would nest a second document inside the first.
Sourcepub fn brand(self, brand: Brand) -> Self
pub fn brand(self, brand: Brand) -> Self
Sets branding for the shell (sidebar header and the topbar below md) and for the sign-in
and sign-up pages, above their card.
Sourcepub fn dark_by_default(self) -> Self
pub fn dark_by_default(self) -> Self
Starts a visitor who has not chosen a theme dark; without the call the panel starts light.
Sourcepub fn auth<U: PanelUser>(self, auth: Auth<U>) -> Self
pub fn auth<U: PanelUser>(self, auth: Auth<U>) -> Self
Configures this panel’s authentication, defaulting to password auth; sessions belong to the panel that signed them in.
Sourcepub fn endpoint(self, endpoint: impl Route) -> Self
pub fn endpoint(self, endpoint: impl Route) -> Self
Declares a shard or procedure the app serves on this panel’s pages: a request to it answers as a request under the prefix does.
Topcoat serves shards and procedures under its runtime path, outside every panel’s
prefix. A request to an endpoint no panel declares runs as the panel whose session sent
it, whichever panel that is; a panel without sign-in has no session, so its request runs as
no panel, and scoped_query and the url helpers
find no resource. A declared one runs as this panel: a session another panel
signed in is no session here, so it answers 401 when the panel requires sign-in, and
auth::guard, auth::user, the tenant and
can_list answer for this panel. Several panels may declare the same
endpoint; each of their sessions reaches it.
The router still registers the endpoint, through discovery or .route(..). A route
outside Topcoat’s runtime path refuses to mount: the panel’s prefix or the app’s own
layers guard it.
#[shard]
async fn revenue(cx: &Cx) -> Result<impl View> {
auth::guard(cx)?;
Ok(view! { "…" })
}
Panel::new("admin").endpoint(revenue);Adds item to the user menu in the sidebar’s footer, above Sign out: a profile or a
settings page. The item names its URL with
NavigationItem::at; its badge shows in the sidebar only.
Shows B above every page, under the bar that names whom a user acts as; several show in
the order they are added.
Sourcepub fn notifications(self) -> Self
pub fn notifications(self) -> Self
Shows a bell in the topbar over the signed-in user’s stored notifications: an unread count, and a sheet that lists them newest first, to mark read or unread and remove. Once the page loads, the bell keeps updating over Topcoat’s runtime connection, and a notification sent while the page is open also shows as a toast.
The bell lists what was sent on this panel for the tenant the user acts for, and what was
sent for no tenant; its procedures mark and remove only those, so an id it does not list
changes nothing. An open bell checks the session again before it shows anything new, and
auth::revoke_sessions_for_user stops the user’s
open bells at once. A send wakes the bells open on the process that sent it: behind a load
balancer, a bell on another process shows the notification on its next page load.
The panel needs auth and the StoredNotification model, which
models! registers. Nothing deletes stored notifications on its own.
Panel::new("admin").notifications();Sourcepub fn time_zone(self, zone: TimeZone) -> Self
pub fn time_zone(self, zone: TimeZone) -> Self
Sets the time zone a user whose PanelUser::time_zone names
none reads and enters wall-clock times in, and that an unsigned request uses: UTC by
default. See time_zone.
let zone = jiff::tz::TimeZone::get("Europe/Paris").expect("the zone database names it");
Panel::new("admin").time_zone(zone);Sourcepub fn locales(self, locales: impl IntoIterator<Item = Locale>) -> Self
pub fn locales(self, locales: impl IntoIterator<Item = Locale>) -> Self
Sets the locales the panel offers, the default first: en by default. Each request reads
one, which topcoat::l10n::locale() returns: the one the visitor picked from the shell’s
language menu, else the signed-in user’s PanelUser::locale,
else the browser’s best match, else the default. A locale matches the closest offered one
of its language and script, as fr-CA reads fr and zh-HK reads zh-Hant.
Offering several shows the language menu in the sidebar footer, each locale named by an
attribute of the message tablo-shell-language and falling back to its tag; picking one
remembers it in a cookie under the panel’s prefix, and on the signed-in user’s account
through Authenticator::store_locale.
Tablo translates its own words into en and fr; a locale it does not translate needs the
app’s catalog, from translations. The pseudo-locale en-XA spells
every catalog’s English with accented letters, so a word that reads plain on its page is one
no catalog spells.
use topcoat::l10n::locale;
Panel::new("admin").locales([locale!("fr"), locale!("en")]);The panel refuses to mount an empty list, a repeated locale, and a locale no catalog translates.
Sourcepub fn translations(self, locale: Locale, catalog: impl Into<String>) -> Self
pub fn translations(self, locale: Locale, catalog: impl Into<String>) -> Self
Adds the app’s Fluent catalog for locale: the messages its Texts name,
and any of Tablo’s it spells otherwise.
A message the request’s locale leaves out reads from the default locale’s catalogs, then
from English; an English locale reads English alone. A catalog for a language, such as fr,
serves its regions too: fr-CA reads it under its own.
Panel::new("admin")
.locales([locale!("fr"), locale!("en")])
.translations(locale!("fr"), include_str!("../locales/fr.ftl"))
.translations(locale!("en"), include_str!("../locales/en.ftl"));Tablo’s messages name a tenant through the Fluent term -tenant, so an app that calls its
tenants teams overrides that one term in each of its catalogs rather than each message.
Each locale’s term has the forms its messages read, listed atop Tablo’s catalog for it;
the French one adds a .gender attribute the messages agree with:
-tenant = { $form ->
*[lower] team
[upper] Team
[upper-plural] Teams
[indefinite] a team
}The panel refuses to mount a catalog that is not valid Fluent, and one for a locale it does
not offer, en aside.
Sourcepub fn without_global_search(self) -> Self
pub fn without_global_search(self) -> Self
Leaves out the global search the panel otherwise shows: the box in its topbar and its results page.
By default the topbar holds a search box over every resource the panel mounts, unless a
resource opts out with
ResourceDef::without_global_search. As the
user types, it lists up to 5 results of each resource, headed by the resource’s plural
label in the sidebar’s order, and links the page at {prefix}/-/search?q=, which lists up
to 25 of each and which Enter opens, with or without JavaScript. Cmd+K, or Ctrl+K, focuses
the box from anywhere on the page. The term matches a record
when each of its first 8 distinct words does;
ResourceDef::global_search says what a word matches,
which records show and what each opens.
A resource is not searched when it has no searchable column and no title column, or when no result could open a page: its records have no URL, or it has neither a detail page nor a form. The box shows to a request that may list one of the searched resources, and a panel that searches none serves no results page.
The box’s results are a shard the panel declares as its endpoint, so they answer for a panel without sign-in too. Topcoat serves it at one path for every panel, and runs it as the panel that signed the session in, else as the first such panel without sign-in. On any other panel, such as the second of two without sign-in, the box lists no results as the user types and links the results page alone.
Panel::new("admin").without_global_search();Sourcepub fn login_hint(self, hint: impl Into<Text>) -> Self
pub fn login_hint(self, hint: impl Into<Text>) -> Self
Renders a line under the sign-in card for demo credentials or deployment hints.
Sourcepub fn limits(self, limits: Limits) -> Self
pub fn limits(self, limits: Limits) -> Self
Sets the bounds the panel holds each request to: rows exported and imported, records one
bulk action runs on, and bytes a request body holds. Limits::default
lists the defaults.