# Building UI with PermDock

Source: https://permdock.com/docs/concepts/ui

Hidden versus disabled, menus, filtered lists, tenant switchers, role chips, request-access buttons, impersonation banners, view-as previews and role editors, built from the snapshot-backed client instance with usePermission, usePermissions, useFilter, useTenant, useMemberships, useRoles, useAssignableRoles, useAssignablePermissions, useApproval, useSubject, useDescribe and describe(decision), with provider-wide slot defaults; the same names in React, React Native, Vue, Svelte and Solid.

The UI half of PermDock has one job: render what the current subject may do, in the active tenant, without a round trip per element and without a second copy of the policy. Everything on this page is built on the snapshot-backed client instance ([snapshots](/docs/concepts/snapshots)): a `PermDock` with the same `can`, `decide`, `filter` and `pick` as the server, evaluated against the same portable conditions, plus the tenancy introspection from [tenancy](/docs/concepts/tenancy). Client answers are hints; the server adapter that performs a mutation checks again ([threat model](/docs/security/threat-model)). Use `pick` after `filter` when a grant lists `fields`.

The hooks below are direct exports of `permdock/react` and `permdock/react-native`; `permdock/vue` exports them as composables, `permdock/svelte` as stores and `permdock/solid` as signals, with the same names and the same return shapes ([parity table](/docs/adapters)). Nothing here imports a policy, a closure or a Node built-in (invariant 8).

## Hidden or disabled [#hidden-or-disabled]

Two honest ways to show a denied action:

| Pattern | Use when | Build with |
| --- | --- | --- |
| Hidden | The user should not know the action exists (admin menus, other tenants' data) | `<Protected>` with no `fallback` |
| Disabled with a reason | The user may learn what would unlock it (upgrade the plan, ask an admin, wait for approval) | `usePermission` plus `describe(decision)` on a disabled control |

`<Protected>` hides. It does not grow a `disabled` mode: a disabled control needs a reason, a tooltip and an accessible name, and those are the component library's job. PermDock gives the reason:

```tsx
const { allowed, status, decision } = usePermission(permissions.post.publish, post)
const why = describe(decision)   // { title: 'Approval required', detail: 'Publishing needs a reviewer', kind: 'approval' }

<Button disabled={!allowed} aria-disabled={!allowed} title={allowed ? undefined : why.detail}>Publish</Button>
```

`describe(decision)` is a pure function (importable from `permdock`, no React) that turns a `Decision` into `{ kind, title, detail, alternatives }` with `kind` one of `granted`, `denied`, `approval`, `tenant`, `delegation`, `server-only`, `upgrade`. `upgrade` means every denial is `not-entitled`, and adds `plans`: the plan keys that would grant the permission, for an upgrade link. `requiredPlans(decision)` returns the same list from any decision (empty unless every denial is `not-entitled`). A decision carries the permission key, not the leaf, so pass the leaf as `describe(decision, { permission })` to use its `meta.title` in place of the key in the `granted` and `approval` text. It never includes role names or condition internals a user should not see. Adapters use the same function for Problem Details `detail`, so the tooltip and the API error say the same thing. `describe(decision, { messages })` localises the text: `messages` has `titles` per `kind`, `reasons` per denial reason (joined with `separator`, default `', '`) as a record or as `(reason, decision) => string | undefined`, and the functions `granted(permission)`, `approval(permission, approvers)` and `upgrade(plans)`. Anything left out keeps the English default. The `reasons` function sees the whole denied decision, for a translation layer that needs more than the code; returning `undefined` keeps the reason code, as a missing record entry does.

```ts
describe(decision, {
  messages: {
    titles: { denied: "Geweigerd", approval: "Goedkeuring nodig" },
    approval: (permission, approvers) =>
      `${permission} wacht op ${approvers.join(" en ")}.`,
    reasons: (reason, decision) =>
      reason === "no-grant"
        ? t("denied.noGrant", { count: decision.alternatives.length })
        : undefined,
  },
});
```

There is no `useDecision` alias; `usePermission` already returns the `decision`.

## Provider defaults [#provider-defaults]

Set what every `<Protected>` renders for a slot it leaves out, and the localised `describe` text, once on the provider:

```tsx
<PermDockProvider
  snapshot={snapshot}
  defaults={{
    pending: <Skeleton />,
    fallback: null,
    approval: (decision) => <RequestAccess token={decision.token} />,
  }}
  messages={{ titles: { denied: "Geweigerd" } }}
  onSnapshot={(snapshot) => cache.set(snapshot)}
>
  {children}
</PermDockProvider>
```

| Prop | Effect |
| --- | --- |
| `defaults` | `PermDockDefaults`: `pending`, `fallback` and `approval` content for every `<Protected>` below that omits its own |
| `messages` | `DescribeMessages` for `useDescribe()` (`getDescribe()` in Svelte); a call's own `messages` win |
| `onSnapshot` | Called with each snapshot the store accepts, including the initial one |
| `onClear` | Called when the store drops its snapshot |
| `approvalInterval` | Milliseconds between `useApproval` status polls; default 2000 |

`<Protected approval>` renders for an `approval-required` decision and receives it, so the slot can offer a request-access button with the token. Resolution runs from the component's own prop to the provider default: `approval` falls back to the component's `fallback`, then the provider's `approval`, then the provider's `fallback`. A component that sets `fallback={null}` therefore stays hidden whatever the provider sets. In Svelte the props are snippets, and the `children` snippet receives a `GrantedDecision`. In Vue the defaults are render functions that get the slot props. The [Next.js](/docs/adapters/next) server provider takes nodes and message records only, because a function cannot cross from a Server Component to the client.

```tsx
const explain = useDescribe();
const why = explain(decision, { permission: permissions.post.publish });
```

## Menus and toolbars [#menus-and-toolbars]

A menu is a list of permissions on one resource. `usePermissions` evaluates several references at once and returns one entry per reference, so a toolbar renders from a single snapshot pass:

```tsx
const actions = usePermissions(
  [permissions.post.update, permissions.post.publish, permissions.post.delete],
  post,
);
// actions[permissions.post.update.key] -> { allowed, status, decision }
// actions.granted -> [permissions.post.update]      references, not strings
```

It is the client form of AuthZEN action search. The input is references (typed, rename-safe), the output is keyed by `.key` for lookup and exposes the granted references as an array. Entries that need the endpoint batch into one `evaluations` call.

## Lists [#lists]

`useFilter(permission, rows)` runs `filter` against the snapshot and returns the rows the subject may act on, memoised on the row identities:

```tsx
const editable = useFilter(permissions.post.update, posts);
```

For rows with a closure-backed grant the hook returns `{ rows, partial: true }` and the list should render a `pending` state for the excluded rows or ask the server for the filtered page; a list should never be assembled client-side from an unfiltered query the server would have refused. Server components use `permdock.filter` directly.

## Tenant switcher [#tenant-switcher]

```tsx
const { tenant, tenants, switchTo } = useTenant();
// tenant: 'o_acme' | null       the active tenant of the snapshot
// tenants: string[]             every membership tenant, from the snapshot
// switchTo(id): Promise<void>   see below
```

`switchTo` does one of two things depending on the provider. With `snapshot({ tenants: 'all' })` the client already holds every membership's grants and the switch is local and synchronous. With the default per-tenant snapshot the provider calls `refresh({ tenant })`, which asks the server for a snapshot with another active tenant; the server resolves the request against the subject's memberships and answers with `no-membership` for a tenant it does not hold, so a forged id yields an empty snapshot, not another tenant's grants. `PermDockProvider` accepts a `tenant` prop for the initial value, and `<Protected>` accepts `tenant` to render against a derived instance:

```tsx
<Protected
  permission={permissions.billing.plan.change}
  tenant="o_globex"
  fallback={null}
>
  ...
</Protected>
```

Provider hooks such as Clerk's `useOrganization`, Better Auth's `useActiveOrganization` or WorkOS AuthKit's `organizationId` remain the source of truth for which organisation the session is in; the recipe wires their change handler to `switchTo`, and PermDock adds the permission-aware half. Display names and logos come from the provider; PermDock only knows ids.

## Memberships and role chips [#memberships-and-role-chips]

```tsx
const memberships = useMemberships(); // Membership[] from the snapshot
const roles = useRoles(); // Role[] held in the active tenant
const { roles: teamRoles } = useRoles({ team: "t_design" });
```

Render a "Your roles in Acme" chip list from `useRoles()`, an organisation list from `useMemberships()` filtered to `tenant` entries, and a shared-with-me list from `on` entries. The display label for a role is `role.meta.title` on the `Role` leaf; the snapshot's `vocabulary` carries it, so the client needs no catalog for titles.

## Request access [#request-access]

`useApproval` handles the `approval-required` outcome end to end on the client:

```tsx
const { decision } = usePermission(permissions.post.delete, post);
const approval = useApproval(decision);
// approval.state: 'not-needed' | 'required' | 'pending' | 'approved' | 'rejected' | 'expired'
// approval.request(note?): Promise<void>   POSTs to approvalsHandler, creates the ApprovalRequest
// approval.token                            the replay-safe token, for the retry header
```

`request` posts to the `approvals` URL ([approvals adapter](/docs/adapters/approvals)); while a component subscribes, `state` polls `GET <approvals>/<token>` on the `approvalsHandler` every two seconds until the request is `approved`, `rejected` or `expired` (a `404` reads as `expired`), and never during a server render or after logout; when `approved`, the retry helper `approvalHeaders(token)` returns `{ 'PermDock-Approval': token }` for the mutation call ([security: approvals](/docs/security/approvals)). The approver never answers through this hook: approving happens in the inbox or a chat surface, authenticated as the approver. The hook never fabricates a token; it only carries the one the decision returned.

## Impersonation banner [#impersonation-banner]

Support access is modelled as the customer being the `principal` and the support engineer the `actor` with a time-bound delegation ([tenancy](/docs/concepts/tenancy) adjacent features). The client can tell:

```tsx
const { principal, actor, delegation, expiresAt } = useSubject();
{
  actor && (
    <Banner>
      Viewing as {principal.id} · acting as {actor.id} · until{" "}
      {format(expiresAt)}
    </Banner>
  );
}
```

`useSubject` exposes the snapshot's subject: the principal summary, the actor (id and kind, never its secrets) and the delegation. It is also the right hook for "signed in as a service account" and for hiding personal settings when `principal.kind` is not `'user'`.

## View as (simulated snapshots) [#view-as-simulated-snapshots]

An admin wants to see the app as a Globex viewer would. On the server:

```ts
const preview = permdock
  .simulate({ tenant: "o_globex", roles: ["viewer"] })
  .snapshot();
// preview.simulated === true
```

The provider renders from it like any snapshot, `useSubject().simulated` is `true` so the app can show a "Preview" bar, and the decision endpoint refuses `evaluations` and approval requests whose snapshot is simulated, so a preview can never produce a real token or a real mutation. Simulated snapshots are for admins; the server only produces one for a subject that holds `permissions.admin.previewAs` or an equivalent grant you declare.

## Role editor recipe [#role-editor-recipe]

A tenant admin composes a custom role from assignable declared roles, single permissions, or both. The pieces are already there:

1. `permdock catalog --format json` gives the assignable roles with `meta.title`, `meta.description` and the permissions each contains; render it as the palette.
2. `useAssignableRoles()` and `useAssignablePermissions()` return the roles and the ceiling permissions the current subject may hand out in the active tenant: the ceiling of assignable declared roles, narrowed by `RoleSource.assignable(activeTenant)`, intersected with what the subject holds there. A viewer cannot compose an admin; a role marked `meta.manageRoles` lifts the intersection.
3. The form submits `{ tenant, name, includes, grants }` to a server action that validates it against the published Standard JSON Schema for `CustomRole`, checks `permissions.member.assignRole`, runs `validateCustomRole(policy, role)` and compares it with `permdock.assignablePermissions()` again on the server, shows any `dropped` keys, and writes to the app's own `RoleSource` backing table. [Custom roles](/docs/concepts/custom-roles) has the matrix-editor code.
4. Assigning the role to a member is a write to the auth provider's membership (Better Auth `member.role`, Clerk organization membership, your table) followed by `refresh()` on the affected client, or a CAEP event when the provider emits one.

A hosted editor over the same shape is a PermDock Cloud candidate; the open-source recipe is complete without it.

## Data-fetching libraries [#data-fetching-libraries]

The snapshot is state, not server data: it lives in the provider's external store and is read through `useSyncExternalStore`, so it composes with TanStack Query, SWR or a router loader without wrapping. Recipes:

* **Loader**: fetch the snapshot in the route loader (TanStack Router, React Router) and pass it to `PermDockProvider`; call `refresh()` after mutations that change roles.
* **TanStack Query**: `queryClient.invalidateQueries` and `permdock.invalidate(permissions.post)` in the same `onSuccess`, so cached rows and cached endpoint answers expire together.
* **Server Components**: no hook; `getPermission` and `permdock.filter` on the server, snapshot only for the client tree. Pass the snapshot unawaited as `snapshotPromise` so the layout never blocks: hooks answer `status: 'pending'` until it resolves and `<Protected pending>` renders its `pending` slot, so permission UI stays in the static shell; the `suspend` prop suspends readers through `use()` instead ([Next.js adapter](/docs/adapters/next)).

## Status handling [#status-handling]

Every hook returns `status` from the same vocabulary (`ready`, `pending`, `stale`, `server-only`) and `allowed` is always a boolean ([snapshots](/docs/concepts/snapshots)). Rules that keep the UI honest: never block navigation on `pending`; render the last answer on `stale`; treat `server-only` as denied; and never flash a denied state for a portable grant (the React example asserts this in a browser-mode test).

## Testing [#testing]

`snapshotFixture(policy, subject, { tenant, tenants: 'all', simulated })` from `permdock/testing` builds every snapshot shape above, so a tenant switcher, a role-chip list or a preview bar renders in a component test or a Storybook story without a server ([testing](/docs/adapters/testing)). `mswHandlers` answers the decision endpoint and `approvalsHandler` route for the request-access flow.

## What PermDock does not ship [#what-permdock-does-not-ship]

* Components beyond `<Protected>` and the `<PermissionBoundary>` error boundary (in `permdock/next/client`, `permdock/vue`, `permdock/svelte` and `permdock/solid`): no `<Can>`, no tenant-switcher dropdown, no role badge, no approval dialog. Hooks return data; your design system renders it ([tenancy](/docs/concepts/tenancy) alternatives). The [devtools panel](/devtools) on the docs site is a canned explorer, not a package export ([devtools](/docs/getting-started/devtools)).
* Tenant display names, logos or invitation flows: the auth provider owns them.
* A client-side copy of the policy: conditions travel in the snapshot, closures stay on the server.
