[data-reveal]{opacity:1!important;transform:none!important}
Auth requests & permissions
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 uses these composables to call the DMS backend with the current session and recover when it expires. For most custom components, start with three:

  • useApiData loads data the component displays, including during server rendering.
  • useAuthFetch supplies $authFetch for writes and requests triggered by a click.
  • usePermissions controls which UI actions the user sees; backend routes still enforce access.

The later sections cover the current user, account switching and logout. useSessionRecovery explains the shared recovery mechanism; you rarely need to call it directly.

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

useApiData — read what a component renders

useApiData is the one way a component loads backend data it renders. It is Nuxt's useFetch and useAsyncData over $authFetch, on the renderer's useDmsFetch and useDmsAsyncData:

// One URL. The URL and `query`, `params`, `body` and `method` may be refs or
// getters: the data follows them.
const { data, status, error, refresh } = useApiData<Task[]>("/api/task", {
  query: () => ({ status: filter.value }),
  default: () => [],
});

// A URL that is null or undefined reads nothing until it has a value.
const { data: task } = useApiData<Task>(() =>
  taskId.value ? `/api/task/${taskId.value}` : null,
);

// Data made of several requests: a key, and a handler given `$authFetch`
// and the signal to pass on.
const { data: board } = useApiData("task-board", async ({ $authFetch, signal }) => {
  const [tasks, stats] = await Promise.all([
    $authFetch<Task[]>("/api/task", { signal }),
    $authFetch<TaskStats>("/api/task/stats", { signal }),
  ]);
  return { tasks, stats };
});
  • A server render waits for it and hands the result to the browser with the page, which then does not ask again; awaiting the call is optional. The callers of one request (method, URL, query and body) share it.
  • Stale while revalidate. Data read before shows at once and is read again in the background each time a component showing it mounts, and each time a reactive request comes back to it. The first mount after a server render does not read again: its data is the server's, just read.
  • The page reads it again. refreshPageBlocks() (what a form or a header button calls once it saved; await it to go on once the reads are done) and each event of one of the page's realtime topics read again every useApiData the page shows, through the page's live gate on a live page. No refresh() after a write: the page refresh covers it. refreshOnPageRefresh: false opts a read out, for data that refreshes on triggers of its own (usePageLiveRefresh).
  • Per account and workspace. The DMS keeps the data under a scope of the session's account and workspace (a digest, set on both sides before the page renders): a switch of either drops the previous one's data, and the same key never shows another account's.
  • lazy: true lets a client navigation paint before the data arrives (the server still waits). server: false leaves it to the browser: for data that depends on what only the browser knows (local storage, the window).
  • immediate: false waits for execute(): a dialog or a drawer reads what it shows once it opens. refreshInterval: ms reads again on an interval while a component shows the data and the tab is visible: polling. It may be a ref or a getter, and 0 or null stops it; a call from a plugin polls for as long as the application runs. On a page declared with live, a component's polling stops while the page is paused and reads once on resume if a poll fell due meanwhile; a plugin's keeps running.
  • useApiPagedData(key, ({ $authFetch, cursor, signal }) => …, { nextCursor }) reads a list page by page under one key, for a "Load more" button or an infinite list: the server renders the first page, loadMore() appends the next one, pages, hasMore and loadingMore follow it, and a refresh reads every loaded page again. Put the folder or the filters in the key: a new key starts from its first page.
  • data is a shallow ref (deep: false): replace the value (data.value = { ...data.value, name }) rather than changing it in place, or pass deep: true.
  • refresh() reads again; clear() resets the data to its default. A failure is in error (the backend's statusCode and data included) until the next answer, and a failed refresh keeps the previous data. key is the key the data is loaded under, passed to transform(value, { key }) too; placeholder is true while a reactive request still shows the previous one's data.
  • Requests go through $authFetch, so they carry its headers and its session retry (below).
  • ApiDataResult<T> is what it returns (auto-imported, like UseApiDataOptions<T>): the type a composable sharing a read declares.
  • useDmsData(key) updates a read in place, from a write's answer or a realtime event, without reading it again (see below).

The result's type is ApiDataResult<T> (from #dms-core/app/composables/auth/useApiData), for a composable that returns one:

export function useTask(id: MaybeRefOrGetter<string>): ApiDataResult<Task> {
  return useApiData<Task>(() => `/api/task/${toValue(id)}`);
}

A write, or a read on a click (a dialog's confirmation, an export), stays an $authFetch call: useApiData is for what a component renders.

Three patterns

Chained reads. A read that needs another's answer keys on it: its URL is null until the first one answers, so it reads nothing meanwhile, on the server as in the browser.

const { data: project } = useApiData<Project>(() => `/api/project/${id.value}`);
const { data: owner } = useApiData<User>(() =>
  project.value ? `/api/user/${project.value.ownerId}` : null,
);

An editable draft. data is what the backend holds; the form edits a copy, taken when an answer arrives, so a background read never overwrites what the user typed, and a save sends the copy.

const { data } = useApiData<Settings>("/api/settings");
const draft = ref<Settings | null>(null);
watch(data, (value) => {
  if (value && !isDirty.value) draft.value = structuredClone(value);
}, { immediate: true });

async function save() {
  await $authFetch("/api/settings", { method: "PUT", body: draft.value });
  refreshPageBlocks(); // the page, this read included, reads again
}

Readers and writers. The components that show a record read it with useApiData and the same URL, so they share one request and one cache; the one that changes it writes with $authFetch, then calls refreshPageBlocks(), and every reader on the page reads again. None of them calls another's refresh(). A composable that returns useApiData reads on the server too: a button that only writes must not call it to reach a mutation, so give the writes a composable of their own.

Never fetch in setup, or in an immediate watcher, with $authFetch. It runs on the server too, which neither waits for it nor hands its answer over: the request is lost and the browser asks again. Read with useApiData, whose URL and query may be refs or getters.

Update a read in place: useDmsData

useDmsData(key) (the runtime's, as Nuxt's useNuxtData) gives the data a useApiData read holds under key, shared with every component showing it. Assigning it updates them all without reading again (changing it in place does too when the read is deep: true; reads are shallow by default): put a write's answer in place (an optimistic update), or apply what a realtime event says changed, even to a read the component does not show. It reads nothing itself, so a writer composable can use it (see "Readers and writers" above).

  • The handler form's key is the first argument; a URL read takes a key option for it: useApiData("/api/task", { key: "tasks" }).
  • data is undefined until a component reads the key, while a read whose key moved (a ref or getter key) still shows the previous key's data, and once the browser lets the read go (of the reads no component shows, it keeps the 50 most recently shown) or the account or workspace changes. Assigning it then does nothing: the next read loads it fresh.
  • A read in flight still writes its answer over the update: once the write is done, refresh() the read (or refreshPageBlocks(), which reads again every read of the page that has not opted out with refreshOnPageRefresh: false) when its answer does not carry the new data.
app/composables/useTaskChanges.ts
export const useTasks = () => useApiData<Task[]>("/api/task", { key: "tasks", default: () => [] });

export function useTaskChanges() {
  const { $authFetch } = useAuthFetch();
  const { data: tasks } = useDmsData<Task[]>("tasks");
  return {
    // Optimistic: shown at once, put back if the backend refuses it.
    async rename(id: string, title: string) {
      const previous = tasks.value;
      if (previous) tasks.value = previous.map((task) => (task.id === id ? { ...task, title } : task));
      try {
        await $authFetch(`/api/task/${id}`, { method: "PATCH", body: { title } });
      } catch (error) {
        tasks.value = previous;
        throw error;
      }
    },
    // A realtime event naming the task it changed: that task is read, not the list.
    async applyChange(id: string) {
      const task = await $authFetch<Task>(`/api/task/${id}`);
      tasks.value = tasks.value?.map((current) => (current.id === id ? task : current));
    },
  };
}

useAuthFetch — the authenticated fetch

useAuthFetch() returns $authFetch. Use it for every backend request that is not a useApiData read:

const { $authFetch } = useAuthFetch();

await $authFetch(`/api/task/${id}`, { method: "DELETE" });
  • $authFetch is a $fetch.create instance bound to the backend baseURL (config.public.dms.baseURL). It carries the same call signature as $fetch, including $authFetch.raw(...).
  • useAuthFetch() returns $authFetch alone: the former useFetchAuth is gone, use useApiData.

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,
  loadPermissions,
  fetchPermissions,
  refreshIfStale,
  hasPermission,
  clear,
} = usePermissions();
MemberTypePurpose
permissionsComputedRef<Set<string>>the loaded permission ids
isLoadedComputedRef<boolean>true once a fetch has populated the set
loadPermissions()() => Promise<void>loads the set unless it is loaded, joining a load in flight
fetchPermissions()() => Promise<void>loads GET /dms/permissions again 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 async data under the dms-permissions key, loaded once and reused everywhere; like every key, it belongs to the account and workspace, so switching either drops it. A server render loads it before the page renders (the auth middleware of a page declaring auth) and hands it to the browser with the page, so the browser does not ask for it again; its 5-minute age counts from that load. Reading the set never loads it: a component that shows it does not read it again on mount. Call loadPermissions() when you only need a set: concurrent first loads share one request. fetchPermissions() is for a refresh (the set changed on the backend): asked while a load is in flight, it replaces that load rather than taking its older answer, and a failed refresh keeps the last set. clear() drops the answer of a load in flight too, so a logout or account switch can't be overwritten by a stale response. Call refreshIfStale() on navigation to keep the set 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.

DmsPermissionGate

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

<template>
  <DmsPermissionGate permission="dms.task.write">
    <UButton @click="createTask">New task</UButton>
    <template #fallback>
      <p>You don't have access to create tasks.</p>
    </template>
  </DmsPermissionGate>
</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 frontend 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 DMS backend; same-origin frontend 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 reads this user, so a refresh() propagates a new name everywhere at once without re-logging in. refresh() re-fetches the session 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 sidebar 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.