[data-reveal]{opacity:1!important;transform:none!important}
Frontend Composables

Auth requests & permissions

The composables a layer's Vue uses to call the backend with auth, gate UI by permission, switch accounts, and recover expired sessions.

A layer's Vue talks to the DMS backend through a small set of composables. They handle the parts you should never reimplement: attaching the session token, recovering an expired one, and reading the current user's permissions. As a layer author you'll touch five of them: useAuthFetch (every backend call), usePermissions (gate the UI), useMultiAccount (the account switcher), useLogout (the shared logout), and useSessionRecovery (the shared refresh primitive underneath).

The permission ids these read come from the backend RBAC layer — see Auth & permissions for where they're registered and how routes enforce them.

useAuthFetch — the authenticated fetch

useAuthFetch() returns two callables. Use them for every backend request:

const { $authFetch, useFetchAuth } = useAuthFetch();

const tasks = await $authFetch<Task[]>("/api/task"); // imperative, like $fetch
const { data } = useFetchAuth<Task[]>("/api/task"); // SSR-aware, like useFetch
  • $authFetch is a $fetch.create instance bound to the backend baseURL (config.public.cms.baseURL). It carries the same call signature as $fetch, including $authFetch.raw(...).
  • useFetchAuth is a thin useFetch wrapper that passes $authFetch as its $fetch, so you get SSR hydration, data/pending/error refs, and reactive keys — all over the authenticated transport.

Headers it injects

On every request $authFetch sets:

HeaderValueWhen
AuthorizationBearer <session.accessToken>whenever a session access token exists
x-realtime-sessionthe active realtime session idonly when one has been registered (see Realtime & notifications)
x-content-languagethe current i18n localealways (set as a default header on the instance)

You don't set these yourself — calling $authFetch is enough.

usePermissions — gate the UI

usePermissions() exposes the current user's permission set from shared state and the helpers to query it:

const { permissions, isLoaded, fetchPermissions, refreshIfStale, hasPermission, clear } =
  usePermissions();
MemberTypePurpose
permissionsRef<Set<string>>the loaded permission ids
isLoadedRef<boolean>true once a fetch has populated the set
fetchPermissions()() => Promise<void>loads GET /cms/permissions into shared state
refreshIfStale()() => Promise<void>refetches only if the last load is older than 5 minutes
hasPermission(id)(string) => booleanmembership check, honoring the * wildcard
clear()() => voidempties the set and invalidates any in-flight load

The set is loaded once into shared useState and reused everywhere. Concurrent callers are coalesced onto a single in-flight request, and a generation counter discards results that land after a clear() (so a logout or account switch can't be overwritten by a stale response). Staleness is 5 minutes — call refreshIfStale() on navigation to keep it current without refetching on every render.

hasPermission returns true for any id when the set contains the * wildcard, otherwise it's a plain membership test against the ids registered on the backend.

CmsPermissionGate

The component most layers actually use is CmsPermissionGate (a global component, like every base-layer component under the Cms prefix) — it renders its default slot only when permissions are loaded and the user holds the id, with an optional fallback slot otherwise:

<template>
  <CmsPermissionGate permission="cms.task.write">
    <UButton @click="createTask">New task</UButton>
    <template #fallback>
      <p>You don't have access to create tasks.</p>
    </template>
  </CmsPermissionGate>
</template>

Internally that's just v-if="isLoaded && hasPermission(permission)". The permission you pass is a backend permission id — register and enforce the matching one server-side as described in Auth & permissions.

A permission gate is a UI convenience, not a security boundary. The backend still enforces the permission on the route; gating the UI just stops users from reaching actions they can't perform.

useMultiAccount — the account switcher

useMultiAccount() manages a list of signed-in accounts the user can switch between without re-entering credentials:

const {
  accounts,
  getActiveAccount,
  addCurrentAccount,
  removeAccount,
  syncActiveAccount,
  switchAccount,
  validateAllAccounts,
} = useMultiAccount();

The list is persisted in localStorage under the key antelopejs-accounts. Each entry is a StoredAccount:

interface StoredAccount {
  userId: string;
  email: string;
  name: string;
  refreshToken: string;
  lastUsed: Date;
  isExpired?: boolean;
}
MemberPurpose
accountsreactive localStorage-backed list of stored accounts
getActiveAccount()a StoredAccount view of the currently logged-in user (or undefined)
addCurrentAccount()upsert the current user + refresh token into the list
removeAccount(userId)drop an account from the list
syncActiveAccount()copy the session user's current name/email onto their stored entry
switchAccount(userId)switch the active session to a stored account
validateAllAccounts()probe every other account's refresh token, marking dead ones isExpired

switchAccount first saves the current account (so you can switch back), then POSTs /auth/switch-account with the stored refresh token, refreshes the user session, clears permissions and refreshes the site layout. Navigation after a successful switch belongs to the caller — the accounts page navigates to the configured homepage. If the switch is rejected, that account is flagged isExpired and the error rethrown. Clearing permissions on switch matters: it forces usePermissions to refetch for the new identity instead of serving the previous user's set.

Both probing and switching go through the backend's /api/auth/refresh, which rotates refresh tokens past a certain age. When a rotated token comes back, the composable writes it into the stored account — otherwise the localStorage copy would be silently invalidated by its own validation. A probe that cannot reach a verdict (backend unreachable, non-auth error) leaves the stored state untouched instead of guessing. An account marked isExpired stays in the list (badged "expired" on /auth/accounts) until the user removes it; it is never dropped automatically.

/auth/switch-account and /auth/validate-account are Nuxt server routes on the app's own origin, so this composable calls them with a bare $fetch — not $authFetch. The $authFetch rule is about calls to the CMS backend; same-origin Nuxt auth routes are the deliberate exception.

useCurrentUser — the reactive identity

useCurrentUser() returns { user, refresh } — the shared, reactive session user plus the one way to re-sync it after a server-side change. Every surface that displays the signed-in identity (the header user menu, the account switcher) reads this user, so a refresh() propagates a new name everywhere at once without re-logging in. refresh() re-fetches the session (the nitro session hook pulls a fresh /api/auth/me) and then calls syncActiveAccount() so the stored multi-account entry follows. The built-in profile-sync plugin calls it automatically when the profile page's form saves.

useLogout — the shared logout

useLogout() returns { logout }, the logout behavior shared by every surface that offers it (the header user menu, the command palette's session group). logout() clears the session, drops the current user from the multi-account list, then routes to the account switcher (/auth/accounts) when other stored accounts remain — or to /auth otherwise. The session is cleared even when the user object is malformed, so logout never silently no-ops. Call it instead of hand-rolling clearSession + navigation:

const { logout } = useLogout();
await logout();

useSessionRecovery — the shared refresh primitive

useSessionRecovery() is the deduplicated 401-recovery primitive that sits under useAuthFetch (and the realtime stream). You rarely call it directly — it's documented here so the automatic session recovery isn't a black box.

const { loggedIn, refreshSession, reconcileSession, redirectToAuth } = useSessionRecovery();
  • refreshSession()Promise<boolean> — refreshes the user session, resolving true on success. Crucially it runs through runDeduped, so concurrent callers share a single in-flight /api/auth/refresh. The rotating refresh token makes this essential: parallel refreshes with the same token would invalidate each other and log the user out.
  • redirectToAuth(redirect?) — navigate to /auth with a redirect query back to where the user was (a no-op if already on an /auth route).
  • reconcileSession() — refresh only when currently logged in.
$authFetch and the realtime stream both consume this same primitive, which is why a simultaneous 401 on an HTTP call and the live connection coalesce into one refresh rather than racing. Treat it as mostly-internal plumbing: prefer useAuthFetch for requests and let it drive recovery for you.