Skip to main content

Panel

Struct Panel 

Source
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.

The app owns the router and mounts the panel into it with RouterBuilderPanelExt::panel:

let router = Router::builder()
    .discover()
    .app_context(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/login, POST /admin/logout and POST /admin/tenant, the tenant switcher, unless authentication is disabled;
  • /admin/register, /admin/profile, /admin/forgot-password and /admin/reset-password when Auth installs 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

Source

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

Source

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 fetch and 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. 'unsafe-eval' lets Topcoat’s runtime compile its client expressions.
  • 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.

Source

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-identity header, 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",
));
Source

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.

Source

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

Source

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

Source

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.

Source

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

Source

pub fn new(prefix: impl Into<String>) -> Self

Creates a Panel mounted at prefix, defaulting an empty prefix to "/admin".

Source

pub fn prefix(&self) -> &str

Returns the mount prefix, e.g. "/admin".

Source

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.

Source

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.

Source

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.

Source

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

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.

Source

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:

MethodPathServes
GET/admin/usersthe list
GET/admin/users/{id}the detail page; 404 when the resource’s view is empty
POST/admin/users/{id}/deletedelete one record
POST/admin/users/-/bulk-deletedelete the selected records
GET/admin/users/-/exportthe list as CSV
GET, POST/admin/users/-/createthe create form ¹
GET, POST/admin/users/{id}/editthe edit form ¹
GET/admin/users/-/optionsa 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}/optionsan 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}/optionsthe 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.

Source

pub fn resource_with<R: Resource>( self, customize: impl FnOnce(ResourceDef<R>) -> ResourceDef<R> + Send + 'static, ) -> Self

Registers the Resource R as resource does, with customize adjusting its def for this panel only:

Panel::new("portal").resource_with::<PostResource>(|def| def.policy(ReadOnly));
Source

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.

Source

pub fn page_with<P: Page>( self, customize: impl FnOnce(PageDef) -> PageDef + Send + 'static, ) -> Self

Declares the Page P as page does, with customize adjusting its def for this panel only:

Panel::new("portal").page_with::<ReportsPage>(|def| def.access(is_manager));
Source

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.

Source

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.

Source

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.

Source

pub fn dark_mode(self, enabled: bool) -> Self

Sets the theme a visitor who has not chosen one sees, dark when true.

Source

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.

Source

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);
Source

pub fn user_menu_item(self, item: NavigationItem) -> Self

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.

Source

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();
Source

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);
Source

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 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.

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.

Source

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"));

The panel refuses to mount a catalog that is not valid Fluent, and one for a locale it does not offer, en aside.

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. 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();
Source

pub fn login_hint(self, hint: impl Into<Text>) -> Self

Renders a line under the sign-in card for demo credentials or deployment hints.

Auto Trait Implementations§

§

impl !Freeze for Panel

§

impl !RefUnwindSafe for Panel

§

impl !Sync for Panel

§

impl !UnwindSafe for Panel

§

impl Send for Panel

§

impl Unpin for Panel

§

impl UnsafeUnpin for Panel

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> ErasedDestructor for T
where T: 'static,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self> ⓘ

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self> ⓘ

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

Source§

fn vzip(self) -> V

Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self> ⓘ
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self> ⓘ

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more