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, 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): 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. Client answers are hints; the server adapter that performs a mutation checks again (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). 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, 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.
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
Set what every <Protected> renders for a slot it leaves out, and the localised describe text, once on the provider:
<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 server provider takes nodes and message records only, because a function cannot cross from a Server Component to the client.
const explain = useDescribe();
const why = explain(decision, { permission: permissions.post.publish });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. 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(); // 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
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 approvals URL (approvals adapter); 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). 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, single permissions, or both. 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()anduseAssignablePermissions()return the roles and the ceiling permissions the current subject may hand out in the active tenant: the ceiling of assignable declared roles, narrowed byRoleSource.assignable(activeTenant), intersected with what the subject holds there. A viewer cannot compose an admin; a role markedmeta.manageRoleslifts the intersection.- The form submits
{ tenant, name, includes, grants }to a server action that validates it against the published Standard JSON Schema forCustomRole, checkspermissions.member.assignRole, runsvalidateCustomRole(policy, role)and compares it withpermdock.assignablePermissions()again on the server, shows anydroppedkeys, and writes to the app's ownRoleSourcebacking table. Custom roles has the matrix-editor code. - 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. Pass the snapshot unawaited assnapshotPromiseso the layout never blocks: hooks answerstatus: 'pending'until it resolves and<Protected pending>renders itspendingslot, so permission UI stays in the static shell; thesuspendprop suspends readers throughuse()instead (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>and the<PermissionBoundary>error boundary (inpermdock/next/client,permdock/vue,permdock/svelteandpermdock/solid): no<Can>, no tenant-switcher dropdown, no role badge, no approval dialog. Hooks return data; your design system renders it (tenancy alternatives). The devtools panel on the docs site is a canned explorer, not a package export (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.
Last updated on
Extension interfaces
The fixed set of interfaces through which providers, verifiers, signers, stores, sinks and compilers plug into PermDock (SubjectResolver, TokenVerifier, TokenSigner, MembershipSource, RoleSource, ApprovalPolicySource, DirectoryStore, ApprovalStore, DecisionSink, SnapshotSource, LimitStore, ReplayStore, RevocationFeed, 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 evaluation error classes and ends long-lived connections with a fourth, each carrying the Decision or issues that caused it, and adapters map them to Problem Details and model-readable refusals.