Home/ Cookbook/ Auth/ Permission gate

Build route- and component-level permission gates that fail gracefully

By the end of this recipe you'll have a RequireRole wrapper for whole pages, a useRole hook for in-component checks (manager sees "Override", clerk doesn't), and a graceful "Manager only — ask Tendai" fallback page that beats a 404 every time.

Auth 2 primitives · 1 block · 1 pattern ~25 min React · @corelithzw/react

Overview

Two gates at two levels. Route gates wrap whole pages and show a friendly "ask your manager" panel when the role doesn't match. Component gates hide or disable individual controls inline.

The biggest mistake new teams make is treating the JSX gate as a security boundary. It isn't — anyone with the bundle can flip a boolean. The gate is a UX nicety: it stops a clerk from clicking "Override price" and getting a confusing 403 from the server. The server still has to reject the request; the gate is what prevents the user ever sending it.

Gate server-side, not JSX-side. The JSX gate is a UX nicety — never a security boundary. Hiding a button isn't security; rejecting the request is. Both belong in the codebase; only the second one is allowed to be the only one.

When a permission check fails on a whole page, show the user something useful. A blank screen, a 404, or worse — a half-rendered page — all teach the user that the app is broken. The "Manager only" fallback names the role required and the manager on shift, so the user knows who to ask.

Skip this recipe if every user in the app has the same role. Permission gates exist to support role-based features; in a single-role product they're dead code.

What you'll build

A page seen by a manager, the same page denied to a clerk, and the row-level gate for a button.

Huchu · POS

Sale #2841

Manager · Tendai M.

Subtotal$124.00
Discount−$0.00Edit
Total$124.00Override price
TenderCashRefund
01 Manager — Override + Refund show
Huchu · POS

Sale #2841

Clerk · Anesu S.

Subtotal$124.00
Discount−$0.00Edit
Total$124.00
TenderCash

Manager features are hidden — not just disabled.

02 Clerk — same page, fewer actions
Huchu · Payroll
Manager only

Payroll is locked to managers and owners. Tendai M. is on shift.

Ask Tendai to open this →
03 Route gate — "ask Tendai" not 404

Required pieces

Everything this recipe pulls from @corelithzw/react.

@corelithzw/react exports used here: RequireRole, useRole, EmptyState, Button, Stack, RoleSwitcher. The hook and the wrapper share one identity context — they never disagree.

Use the RoleSwitcher primitive. Earlier walk-throughs of this recipe inlined the "Clerk | Manager" toggle as bespoke JSX. The new p-role-switcher primitive ships the pill segmented control with aria-pressed wiring — use it in demo previews and dev banners to flip identity.roleId without rewriting the toggle. Never ship it in production UI where the role is set by the back end.

Step-by-step build

01

Define the role model — roles are sets of permissions, not a string

Treat a role as a bag of Permission strings, not a single role name. That way the same gate works whether you grow from three roles to eight, and you can grant a clerk a single elevated permission without inventing "clerk-plus".

Code-only step

extraPermissions lets you grant a clerk "refund" without renaming them.

The server enforces the same map. JSX gates only mirror.

Switch to the Code tab to see the snippet.

Step 1 · Role model@corelithzw/react
02

Build the useRole hook — the single source of truth

The hook reads the current identity from context, then exposes can(p) and a literal role for label purposes. Components use can; the gate wrapper uses the same primitive so they can't disagree.

Code-only step

When identity is null, every can() returns false.

extraPermissions are merged at read time — no role mutation.

Switch to the Code tab to see the snippet.

Step 2 · useRole
03

Build the in-component gate — hide, don't disable

If the user can't do something, the button isn't there. A disabled button is a tease — it advertises a feature the user can never use, and forces them to inspect it to find out why. The exception is when the user could realistically be promoted — then disable + tooltip.

Code-only step

"Override price" is hidden from clerks — secret, not teased.

"Refund" is disabled with a tooltip — clerks know it exists.

Switch to the Code tab to see the snippet.

Step 3 · In-component gate
04

Build the route-level gate — fall back to "ask Tendai"

Wrap the page in RequireRole. When the check fails, render a friendly empty state that names the role required and the on-shift manager. Never a 404; never a blank screen; never the same page with everything disabled.

Manager only

Tendai M. is on shift and can open this for you.

Ask Tendai to open this

Signed in as Anesu S.

Step 4 · Route gate

Final composition

The whole identity layer, assembled. Provider + hook + route gate + in-component gates + a sample router setup.

Sale #2841

Manager · Tendai M.
Subtotal$124.00
Total$124.00Override price
TenderCashRefund
PermissionGate.tsx@corelithzw/react

Variations

Three common forks. Each is a small diff from the final composition.

Time-of-day gate

Refunds disallowed after 6pm without an owner sign-off. Add a temporal predicate to the permission check.

const allowed = can('sale.refund') && (
  new Date().getHours() < 18
  || can('sale.refund.afterhours')
);
// rest of the gate is identical

Owner sign-off prompt

Instead of "ask the manager", let the clerk request the action — owner gets a push, approves, the action runs.

// Replace EmptyState with a request form
<ApprovalRequest
  permission={permission}
  managerOnShift={managerOnShift}
  onSubmit={(reason) =>
    api.requestOverride(reason)}
/>

Multi-permission gate

Some pages need any-of permissions (manager OR finance). Accept an array and treat as union.

function RequireAny({ permissions, ... }) {
  const { can } = useRole();
  if (permissions.some(can)) {
    return <>{children}</>;
  }
  return <EmptyState ... />;
}

Accessibility

What this recipe takes care of for you.

  • The deny screen is announced. EmptyState renders role="region" + aria-labelledby on its heading, so screen readers announce "Manager only" when the gate triggers.
  • Disabled buttons read their reason. A disabled "Refund" carries aria-describedby pointing at a hidden span — screen readers say "Refund, dimmed, refunds need a manager".
  • The "ask Tendai" CTA is a real button. Not a link styled like a button. Enter fires the handoff request; focus stays predictable.
  • Hidden buttons stay hidden. A button hidden via return null is removed from the DOM entirely, so it can't be reached by Tab or read by a screen reader. Don't visibility: hidden a permission-gated control.
  • The manager's name is visible, not just spoken. Sighted users see who to ask without focusing the CTA. Screen-reader users hear the same name through the description.
  • The server is the truth. A failed server response renders the same EmptyState via the global error boundary, so a stale client never traps a user on a broken page.