Auth requests & permissions
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
$authFetchis a$fetch.createinstance bound to the backendbaseURL(config.public.cms.baseURL). It carries the same call signature as$fetch, including$authFetch.raw(...).useFetchAuthis a thinuseFetchwrapper that passes$authFetchas its$fetch, so you get SSR hydration,data/pending/errorrefs, and reactive keys — all over the authenticated transport.
Headers it injects
On every request $authFetch sets:
| Header | Value | When |
|---|---|---|
Authorization | Bearer <session.accessToken> | whenever a session access token exists |
x-realtime-session | the active realtime session id | only when one has been registered (see Realtime & notifications) |
x-content-language | the current i18n locale | always (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();
| Member | Type | Purpose |
|---|---|---|
permissions | Ref<Set<string>> | the loaded permission ids |
isLoaded | Ref<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) => boolean | membership check, honoring the * wildcard |
clear() | () => void | empties 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.
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;
}
| Member | Purpose |
|---|---|
accounts | reactive 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, resolvingtrueon success. Crucially it runs throughrunDeduped, 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/authwith aredirectquery back to where the user was (a no-op if already on an/authroute).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.