Core

Admin shell

Core owns the admin routes, the sign-in page, the route guard, and the navbar.

Core owns the admin shell. A backend layer does not write it. The backend registers an auth driver, and core builds the shell from it.

The shell

PartRoute or place
Parent route/admin. It redirects to /admin/home.
Sign-in page/admin/login
No-access page/admin/no-access
Not-configured page/admin/not-configured
Route guardA global route middleware for /admin/**
NavbarThe top bar with the user menu
BannersComponents that the admin layout shows above the page

/admin/** has ssr: false. The admin is client-only. The server still checks every request. The guard only decides what the browser shows.

The auth driver

A layer sets authDriver in definePlutoExtension. The last registered driver wins.

FieldPurpose
user()The signed-in user (id, name, username, email, avatarUrl), or null.
loggedIn()Optional. The default is user() !== null.
ready()A promise. It resolves after the first session check. It never rejects.
refresh()Optional. Reads the session again.
signOut()Ends the session. Core then shows a toast and goes to /admin/login.
signInThe sign-in form component. Core passes it the redirect prop.
signInTitle, signInDescriptionText for the sign-in frame. Each is a key or a literal.
linksThe signUp and forgotPassword URLs.
publicPathsPaths that need no sign-in. Each is a string, or { path, signedIn: 'redirect' | 'allow' }. /admin/login is always public. Use signedIn: 'allow' for a page that a signed-in user must still open.

user and loggedIn are getters, not refs. They read reactive state of the backend.

loggedIn() must give the same answer on the server and in the first client render. A backend with a cookie session that the server can read can answer on the server. A backend that learns the session in the browser answers false on the server. The navbar then appears after hydration.

Other registry fields for the shell

FieldPurpose
accountMenuItems in the user menu. Sorted by order.
adminBannersBanner components. The admin layout renders them.
sitename() and url() of the public site.

A banner is a component with no props. It decides for itself if it shows.

The route guard

The guard is pluto-admin-auth.global.ts. It works in this order:

  1. It never touches a path outside /admin.
  2. With no auth driver, it sends every admin path to /admin/not-configured. A backend registers the auth driver, so no driver means that the backend is missing or did not load. Look at the environment variables of the server. In dev, the admin opens with no driver, so you can build admin pages with no backend.
  3. It waits for driver.ready(). A rejection counts as signed out.
  4. It calls driver.guard(to) when the driver has one. A returned local path that is not the current path is a redirect. The supabase layer uses this hook to send a new install to /admin/setup.
  5. It decides with the public paths of the driver. A signed-out user on a private admin path goes to /admin/login?redirect=<fullPath>. A signed-in user on a public auth path goes to the redirect target, or to /admin/home.
  6. For a signed-in user on a private path, it runs the staff step.

The staff step

Only staff can enter the private admin pages. A staff user is a user with at least one capability. An admin holds *.

With no permissions driver, every signed-in user counts as staff.

A signed-in user with no capability goes to /admin/no-access?redirect=<fullPath>. If the capabilities fail to load, the user goes to /admin/no-access?reason=error&redirect=<fullPath>. On this page, staff go to the redirect target, or to /admin/home. A user who is not staff stays.

The page has three actions: Try again (it loads the capabilities again), Go to the site, and Sign out.

Who sees the navbar

  • On /admin/**, any signed-in user sees the bar. The exceptions are the public auth paths and a user who is known to be no staff.
  • On a public page, only staff see the bar. A shop customer never sees it.

On /admin/**, the bar does not wait.

The bar in the first paint

On a public page, the server renders the bar for a browser that showed it to staff before. So the bar loads with the page, and the page does not move.

  1. The first time staff see the bar, the browser sets the cookie pluto-admin-bar. The cookie holds no user data and gives no access.
  2. On the next page load, the server reads the cookie and renders the bar frame: the menu button, the logo, and the site name. The navbar actions and the user menu load after the page starts, with a placeholder.
  3. The browser then checks the session. A wrong cookie (for example, the session ended) hides the bar and removes the cookie. A customer never sees the bar.

A page rendered from the cookie gets Cache-Control: private, no-store, so a shared cache does not keep it. A prerendered page has no bar in its HTML. There, the bar appears after the capabilities load. If the load fails, the bar hides.

Offset your own header

An app that offsets its own header for the bar can use usePlutoAdminBar().visible. It follows the same rule as the bar, so it is in the server HTML too. usePlutoAdminBar().confirmed is true only after the browser checked that the user is staff. Use it for a link that must not show before that check.

Two hooks help a site style:

  • --pluto-admin-bar-height is a CSS token on :root. Its value is 2.5rem. The navbar and the admin layout read it.
  • PlutoRoot sets data-pluto-admin-bar="true" on <html> while the bar shows. It is in the server HTML when the server renders the bar.
:root[data-pluto-admin-bar] .site-header {
  top: var(--pluto-admin-bar-height);
}

--ui-header-height stays 2.5rem on :root. A site with a taller header can set its own --ui-header-height. The admin bar keeps its height.

What the navbar has

In order:

  1. The menu button, for small screens.
  2. The logo and the site name, as a chip. On a small screen, the bar shows only the planet of the logo.
  3. View site, on /admin/** only. It opens site.url(), or /.
  4. The navbar actions.
  5. The color mode button.
  6. The user menu, when a driver is registered. It has the user name, Settings, the accountMenu items, a link to Pluto on GitHub, and Sign out.

An administrator gets a dot on the avatar, and an Admin badge in the user menu. An administrator is a user with the capability *: the admin role of the Supabase layer, or a GitHub user with admin or maintain access to the repository.

The admin sidebar can collapse to a rail of icons: use the button at its bottom. A tooltip names each item, and a group shows its children in a popover. The admin keeps the choice in a cookie.

The site name is site.name(), or runtimeConfig.public.websiteName. The bar hides the chip when the name is empty or Untitled.

A navbarActions entry is a component action ({ id, component }) or a link action ({ id, label, icon?, to, target? }). A link action shows its label from the lg breakpoint. Below it, the label is the aria-label.

New and Edit buttons

Core adds buttons for each content type that sets adminBar (see Content model):

  • A New button (for example New Post) on every page where the bar shows. When two or more types can show one, the bar has one New button instead. It opens a menu on hover (after a short delay), on click, or with the keyboard. Types without a group are items of the menu. A group with two or more types is a submenu. A group with one type shows that type as an item. When all types are in one group, the menu lists them directly.
  • An Edit button (for example Edit Post) on a public page that tells core which item it shows:
app/pages/posts/[slug].vue
const route = useRoute()

// "Edit Post" in the admin bar. It also returns the edit link, or null for a visitor.
const editLink = usePlutoEditLink('post', () => String(route.params.slug))

The write capability of the type decides who sees the buttons. The Edit button does not show in the admin, because the form has a View link. The Git CMS post type sets adminBar: true, so a new blog shows New Post and Edit Post.

The sign-in frame

PlutoAdminAuthCard is the frame of the sign-in page. It shows the logo, the site name, the heading, the signIn component, the footer links, and a Back to the site link.

A backend layer can use the same frame for its other auth pages. Give the card a default slot, and the slot replaces the form. Use the title and description props to replace the heading.

<PlutoAdminAuthCard title="supabase.auth.signup.title">
  <SignUpForm />
</PlutoAdminAuthCard>

The tab title

On admin routes, PlutoRoot sets the title template to Page - Site name. The site name is site.name(), then runtimeConfig.public.websiteName, then the Pluto name. Public routes keep the title template of the app.

Next steps