Skip to main content
The @vertz/ui/auth module provides UI-advisory access checks. The server computes an access set (what the user can do), embeds it in the JWT, and the client uses it to show/hide UI elements without network requests. This is advisory only. The server always re-validates before mutations. Client-side can() controls UI visibility, not authorization.

Setup

If your app uses AuthProvider, enable access control with a single prop:
import { AuthProvider } from 'vertz/ui/auth';

function App() {
  return (
    <AuthProvider accessControl>
      <Router />
    </AuthProvider>
  );
}
This automatically manages the AccessContext.Provider, fetches the access set after authentication, clears it on sign out, and hydrates from SSR.

Standalone with createAccessProvider()

If you’re not using AuthProvider, use createAccessProvider() to bootstrap the access context manually. This is useful when you have your own auth solution or a custom session management layer.
import { createAccessProvider } from '@vertz/ui/auth';
Signature:
function createAccessProvider(): AccessContextValue;
Returns an AccessContextValue with two signal properties:
PropertyTypeDescription
accessSetSignal<AccessSet | null>The decoded access set, or null if not yet loaded
loadingSignal<boolean>true while the access set is being loaded
On initialization, createAccessProvider() checks for window.__VERTZ_ACCESS_SET__. If present (injected during SSR), the access set is immediately available and loading is false — no loading flicker. If not present, loading starts as true until you hydrate it by setting accessValue.accessSet.value directly. Example — wrap your app manually:
import { AccessContext, createAccessProvider } from '@vertz/ui/auth';

function App() {
  const accessValue = createAccessProvider();

  return (
    <AccessContext.Provider value={accessValue}>
      <Router />
    </AccessContext.Provider>
  );
}
Example — hydrate from a custom auth API:
import { AccessContext, createAccessProvider } from '@vertz/ui/auth';

function App() {
  const accessValue = createAccessProvider();

  // Fetch access set from your own endpoint
  fetch('/api/my-auth/access-set')
    .then((r) => r.json())
    .then((data) => {
      accessValue.accessSet.value = data.accessSet;
      accessValue.loading.value = false;
    });

  return (
    <AccessContext.Provider value={accessValue}>
      <Router />
    </AccessContext.Provider>
  );
}

Inject the access set during SSR

On the server, extract the access set from the JWT and inject it as a script tag:
import { getAccessSetForSSR, createAccessSetScript } from '@vertz/ui-server';

// In your SSR handler
const jwtPayload = await verifyJWT(token);
const accessSet = getAccessSetForSSR(jwtPayload);

const html = renderPage({
  head: accessSet ? createAccessSetScript(accessSet) : '',
  body: renderApp(),
});
createAccessSetScript() produces a <script> tag that sets window.__VERTZ_ACCESS_SET__. It escapes all characters that could enable XSS via JSON injection. Pass a CSP nonce if your application uses nonce-based Content Security Policy:
createAccessSetScript(accessSet, request.nonce);
// → <script nonce="abc123">window.__VERTZ_ACCESS_SET__={...}</script>

Checking entitlements with can()

can() checks if the current user has a specific entitlement. Call it in the component body (like query() or form()):
import { can } from '@vertz/ui/auth';

function ProjectSettings() {
  const deleteCheck = can('project:delete');

  return (
    <div>
      <h1>Settings</h1>
      {deleteCheck.allowed && <button onClick={handleDelete}>Delete Project</button>}
      {!deleteCheck.allowed && deleteCheck.reason === 'plan_required' && (
        <p>Upgrade to delete projects.</p>
      )}
    </div>
  );
}

Return shape

can() returns an AccessCheck with reactive properties:
PropertyTypeDescription
allowedbooleanWhether the entitlement is granted
reasonsDenialReason[]All denial reasons, ordered by actionability
reasonDenialReason | undefinedThe most actionable denial reason
metaDenialMeta | undefinedMetadata (usage limits, required plans, etc.)
loadingbooleantrue while the access set is loading
All properties are reactive signals under the hood. The compiler auto-unwraps .value — you use them like plain values in JSX and event handlers.

Denial reasons

Reasons are ordered from most actionable (things the user can fix) to least:
ReasonMeaningUser action
plan_requiredEntitlement requires a higher planUpgrade
role_requiredUser lacks the required roleRequest access
limit_reachedUsage limit exceeded for the billing periodWait or upgrade
flag_disabledFeature flag is offNone
hierarchy_deniedNo access path through the resource hierarchyRequest access
step_up_requiredNeeds recent MFA verificationRe-authenticate
not_authenticatedNo user sessionSign in

DenialMeta structure

When can() returns a denial, the meta property contains structured metadata about why. The DenialMeta type has four optional fields — each is populated only when relevant to the denial reason.
interface DenialMeta {
  requiredPlans?: string[];
  requiredRoles?: string[];
  limit?: { max: number; consumed: number; remaining: number };
  fvaMaxAge?: number;
}
FieldPresent whenDescription
requiredPlansreason === 'plan_required'Plans that include this entitlement (e.g., ['pro', 'enterprise'])
requiredRolesreason === 'role_required'Roles that grant this entitlement (e.g., ['admin', 'editor'])
limitreason === 'limit_reached'Current usage state: max (quota), consumed (used), remaining (available)
fvaMaxAgereason === 'step_up_required'Max seconds since last MFA verification
Use meta to build contextual UI messages:
function FeatureButton() {
  const check = can('ai:generate');

  if (!check.allowed) {
    if (check.reason === 'plan_required') {
      return <p>Available on {check.meta?.requiredPlans?.join(', ')} plans.</p>;
    }
    if (check.reason === 'role_required') {
      return <p>Requires {check.meta?.requiredRoles?.join(' or ')} role.</p>;
    }
    if (check.reason === 'limit_reached') {
      return <p>Usage limit reached. Resets next billing period.</p>;
    }
  }

  return <button onClick={generate}>Generate</button>;
}

Usage limits in meta

When an entitlement has a usage limit, meta.limit contains the current state:
function AIGenerateButton() {
  const aiCheck = can('ai:generate');

  return (
    <div>
      <button disabled={!aiCheck.allowed} onClick={generate}>
        Generate
      </button>
      {aiCheck.meta?.limit && (
        <span>
          {aiCheck.meta.limit.remaining} / {aiCheck.meta.limit.max} remaining
        </span>
      )}
    </div>
  );
}

Entity-scoped checks

For entity lists where items have different permissions, the server can pre-compute access metadata per entity (see server-side entity access). Pass the entity as the second argument to can():
import { can } from '@vertz/ui/auth';

function ProjectCard({ project }) {
  const deleteCheck = can('project:delete', project);

  return (
    <div>
      <h3>{project.name}</h3>
      {deleteCheck.allowed && <button onClick={() => deleteProject(project.id)}>Delete</button>}
    </div>
  );
}
can() checks project.__access['project:delete'] first. If the entity doesn’t have __access metadata, it falls back to the global access set.

AccessGate

AccessGate prevents rendering children until the access set is loaded. Use it to avoid flicker on initial render:
import { AccessGate } from '@vertz/ui/auth';

function App() {
  const accessValue = createAccessProvider();

  return (
    <AccessContext.Provider value={accessValue}>
      <AccessGate fallback={() => <LoadingSpinner />}>{() => <Router />}</AccessGate>
    </AccessContext.Provider>
  );
}
If the access set is hydrated from SSR (window.__VERTZ_ACCESS_SET__), the gate opens immediately with no loading flash. When no AccessContext.Provider is present, AccessGate renders children directly (fail-open for UI).

Compiler integration

can is registered in the signal API registry, which means the compiler auto-unwraps signal properties. You write:
const check = can('ai:generate');
return <button disabled={!check.allowed}>Generate</button>;
The compiler transforms .allowed access to read from the underlying signal. You never need .value in JSX or event handlers.

Without a provider

If can() is called without an AccessContext.Provider in the tree, it returns a fail-secure fallback where allowed is false and reason is 'not_authenticated'. In development, a console warning is logged.

Next steps

Authentication

AuthProvider, useAuth(), sign-in forms, and token refresh.

Server-Side Access Control

Configure hierarchy, roles, plans, and usage limits.

Code Generation

Type-safe entitlements and RLS policies from defineAccess().

Entities

Use access rules to protect entity operations.