PermDock
Concepts

Building UI with PermDock

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, useApproval, useSubject and describe(decision); the same names in React, React Native, Vue, Svelte and Solid.

Status: proposed (ADR 0024) Phase: 1 (React, React Native), 4 (Vue, Svelte, 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): a PermDock with the same can and decide as the server, evaluated against the same portable conditions, plus the tenancy introspection from tenancy. Client answers are hints; the server adapter that performs a mutation checks again (threat model).

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). Nothing here imports a policy, a closure or a Node built-in (invariant 8).

Hidden or disabled

Two honest ways to show a denied action:

PatternUse whenBuild with
HiddenThe user should not know the action exists (admin menus, other tenants' data)<Protected> with no fallback
Disabled with a reasonThe 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:

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. It reads meta.title and meta.description from the permission and the grant's reason, and 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.

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:

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 and resolves the React adapter open question about usePermittedActions: 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

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

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

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:

<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

const memberships = useMemberships()                       // Membership[] from the snapshot
const roles = useRoles()                                   // roles held in the active tenant, custom roles resolved
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 catalog.roles[name].meta.title from permdock catalog; the snapshot carries names only.

Request access

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

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 approvalsHandler route (approvals adapter); state polls or subscribes for the verdict; when approved, the retry helper approvalHeaders(token) returns { 'PermDock-Approval': token } for the mutation call (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

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

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)

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

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

A tenant admin composes a custom role from assignable declared roles. 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() returns the intersection of RoleSource.assignable(activeTenant) and the roles the current subject holds there, so a viewer cannot compose an admin.
  3. The form submits { tenant, name, includes } to a server action that validates it against the published Standard JSON Schema for CustomRole (includes typed as z.enum(policy.assignable)), checks permissions.member.assignRole plus permdock.assignable() again on the server, and writes to the app's own RoleSource backing table.
  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

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 (Next.js adapter).

Status handling

Every hook returns status from the same vocabulary (ready, pending, stale, server-only) and allowed is always a boolean (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

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). mswHandlers answers the decision endpoint and approvalsHandler route for the request-access flow.

What PermDock does not ship

  • Components beyond <Protected>: no <Can>, no tenant-switcher dropdown, no role badge, no approval dialog. Hooks return data; your design system renders it (ADR 0024 alternatives).
  • 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.

Open questions

  • Whether useApproval should subscribe through the SnapshotSource when one is configured (push) or poll the approvalsHandler route (pull, the default).
  • Whether describe should accept a locale or return message keys for next-intl and similar libraries to translate.
  • A useDecision(reference, data) alias returning only the Decision for callers that do not want allowed and status.

On this page