React
AccessProvider, useCan and Can show or hide controls from the server's decisions, with batched record checks.
@zynactra/access-react talks to the access API your service mounts (default /access). It never decides on its own: the server re-checks every request, so UI checks only decide what to show.
Leave requests
| Who | Days | Status | Action |
|---|---|---|---|
| Amara Okafor | 2 | pending | |
| You | 1 | pending |
Client and provider
import { AccessProvider, createAccessClient } from "@zynactra/access-react"
const client = createAccessClient({
baseUrl: "/access",
headers: async () => ({ authorization: `Bearer ${await getToken()}` }), // or cookies
})
export function Root() {
return (
<AccessProvider client={client}>
<App />
</AccessProvider>
)
}The provider loads GET /access/me, the caller's grants. Pass initialSummary when the server already has them (server rendering), and call useAccess().refresh() after the user's own access changes; it also forgets cached record decisions.
useCan and Can
const create = useCan(P.LEAVE_REQUEST_CREATE) // holds it anywhere? no request
const approve = useCan(P.LEAVE_REQUEST_APPROVE, request.id) // this record? batched
<Can permission={P.LEAVE_REQUEST_APPROVE} id={request.id} fallback={null} loading={<Skeleton className="h-8 w-20" />}>
<Button onClick={approveIt}>Approve</Button>
</Can>- Without
id, the answer comes from/me: does the user hold the permission at some scope? Use it for "New" buttons, navigation and whole screens. - With
id, everyuseCanand<Can>in one render goes out as a singlePOST /access/check. The server loads each record with its registered loader and decides; the UI never sends attributes. allowedisundefinedwhile loading;<Can loading>renders something meanwhile.- Errors count as not allowed.
- The decision can change between render and click: still handle 403 from the real request.
Typed calls
The client covers the whole API: client.me(), client.check(...), client.explain(...), client.catalog(), client.listRoles(), client.createAssignment({...}), client.listChanges({ kind, actor, target, severity, limit, before }) and the rest. Errors are AccessApiError with status, code, reason, permission, field (the request field a validation problem is about) and the full problem.
Tests and demos
@zynactra/access-react/testing exports fakeAccessApi, an in-memory API that answers like the real one, plus adminSummary and globalGrant():
import { fakeAccessApi, globalGrant } from "@zynactra/access-react/testing"
const api = fakeAccessApi({
summary: { userId: "u_mgr", orgId: "o", permissions: { "leave.request:approve": globalGrant() } },
decisions: { "leave.request:approve:lr_1": true },
})
render(<AccessProvider client={createAccessClient({ fetch: api.fetch })}><Page /></AccessProvider>)
expect(api.calls.at(-1)?.path).toBe("/access/check")It applies the rules the admin screens depend on, but it is not the rule engine: never use it outside tests, demos and docs.