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:
| 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:
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.
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:
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 stringsIt 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 belowswitchTo 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 headerrequest 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 === trueThe 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:
permdock catalog --format jsongives the assignable roles withmeta.title,meta.descriptionand the permissions each contains; render it as the palette.useAssignableRoles()returns the intersection ofRoleSource.assignable(activeTenant)and the roles the current subject holds there, so a viewer cannot compose an admin.- The form submits
{ tenant, name, includes }to a server action that validates it against the published Standard JSON Schema forCustomRole(includestyped asz.enum(policy.assignable)), checkspermissions.member.assignRolepluspermdock.assignable()again on the server, and writes to the app's ownRoleSourcebacking table. - 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 byrefresh()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; callrefresh()after mutations that change roles. - TanStack Query:
queryClient.invalidateQueriesandpermdock.invalidate(permissions.post)in the sameonSuccess, so cached rows and cached endpoint answers expire together. - Server Components: no hook;
getPermissionandpermdock.filteron 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
useApprovalshould subscribe through theSnapshotSourcewhen one is configured (push) or poll theapprovalsHandlerroute (pull, the default). - Whether
describeshould accept a locale or return message keys for next-intl and similar libraries to translate. - A
useDecision(reference, data)alias returning only theDecisionfor callers that do not wantallowedandstatus.
Extension interfaces
The fixed set of interfaces through which providers, stores, sinks and compilers plug into PermDock (SubjectResolver, MembershipSource, RoleSource, ApprovalStore, DecisionSink, SnapshotSource, LimitStore, WhereCompiler, on() events), their trust classes, the in-process default each ships with, how provider principal types are extended without global augmentation, and the conformance runners in @permdock/testing.
Errors
PermDock throws three error classes, each carrying the Decision or issues that caused it, and adapters map them to Problem Details and model-readable refusals.