Unified authorization guards
requireFeatureFlag / requirePlanFeature / requireAll compose in beforeLoad. Storybook mocks the same session context. Nav gates and route gates can't drift.
One set of guard functions covers feature flags, plan features, and permissions. They compose in beforeLoad, throw typed errors that RouteErrorComponent maps to UX, and read from the same session context that Storybook mocks globally. Nav visibility and route access use the same underlying checks — they can’t drift.
Three access dimensions
| Dimension | Check | Source |
|---|---|---|
| Feature flag | getFeatureFlag(key) |
flag service, or a local override |
| Plan feature | hasPlanFeature(plan, name) |
Plan record from API |
| permission | can(perms, stmt) |
Permissions record from API |
Each guard is a one-liner that throws a typed error on denial:
function requireFeatureFlag(key: string): void {
if (!getFeatureFlag(key)) throw new FeatureFlagDisabledError(key);
}
function requirePlanFeature(client: QueryClient, name: string): void {
const plan = client.getQueryData<Plan>(planQueryKey());
if (!hasPlanFeature(plan, name)) throw new NotInPlanError(name);
}
function requireAll(client: QueryClient, ...stmts: Permission[]): void {
const perms = client.getQueryData<Perms>(permissionsQueryKey());
if (!stmts.every(s => can(perms, s))) throw new AccessDeniedError();
}
function requireAny(client: QueryClient, ...stmts: Permission[]): void {
const perms = client.getQueryData<Perms>(permissionsQueryKey());
if (!stmts.some(s => can(perms, s))) throw new AccessDeniedError();
}
Convenience wrappers for common CRUD patterns:
const requireCanCreate = (client, area) => requireAny(client, { action: "CREATE", area });
const requireCanRead = (client, area) => requireAny(client, { action: "READ", area });
const requireCanUpdate = (client, area) => requireAny(client, { action: "UPDATE", area });
const requireCanDelete = (client, area) => requireAny(client, { action: "DELETE", area });
Route usage
export const Route = createFileRoute("/reports")({
beforeLoad: ({ context: { queryClient } }) => {
requireFeatureFlag("reports-v2"); // must have flag
requirePlanFeature(queryClient, "reports"); // must have plan feature
requireCanRead(queryClient, "reports"); // must have read permission
},
});
Sequential calls are AND. The first failure throws; subsequent checks are skipped.
In-component usage
Same primitives, read synchronously from cache, used for conditional rendering:
function ReportsToolbar({ queryClient }: { queryClient: QueryClient }) {
const perms = queryClient.getQueryData<Perms>(permissionsQueryKey());
const canCreate = can(perms, { action: "CREATE", area: "reports" });
return (
<div>
{canCreate && <Button>Create report</Button>}
</div>
);
}
Nav gate consistency
The same underlying functions gate nav visibility and route access:
// nav config
{
label: "Reports",
path: "/reports" satisfies RoutePath,
hidden: !getFeatureFlag("reports-v2") || !hasPlanFeature(plan, "reports"),
}
// route beforeLoad
requireFeatureFlag("reports-v2");
requirePlanFeature(queryClient, "reports");
If you hide the nav link behind a flag, the route guards the same flag. Structural consistency — not a convention you have to remember.
Storybook: global auth defaults
Every story runs with full admin access and all flags enabled by default, wired in preview.tsx:
// .storybook/preview.tsx
const mockSession: Session = {
accountId: "test-account",
featureFlags: allFlagsOn(), // every flag returns true
plan: fullPlan(), // every plan feature included
permissions: fullAdmin(), // every permission allowed
};
const preview: Preview = {
decorators: [
(Story) => (
<SessionProvider value={mockSession}>
<Story />
</SessionProvider>
),
],
};
Stories never hit real auth. The default session lets every component render its “authorized” state. Specific stories override only the dimensions they’re testing.
Storybook: per-story overrides
// Read-only view
export const ReadOnly: Story = {
decorators: [
withSession({
permissions: noWritePermissions("reports"),
}),
],
};
// Behind a flag
export const FeatureFlagOff: Story = {
decorators: [
withSession({
featureFlags: { "reports-v2": false },
}),
],
};
// Non-plan account
export const NotEntitled: Story = {
decorators: [
withSession({
plan: planWithout("reports"),
}),
],
};
withSession is a decorator factory that merges overrides into the mock session context:
function withSession(overrides: Partial<Session>): Decorator {
return (Story) => (
<SessionProvider value={mergeSession(mockSession, overrides)}>
<Story />
</SessionProvider>
);
}
The same SessionProvider the real app uses. No special Storybook-only auth system to maintain.
Why this works
All three access dimensions — flags, plan features, permissions — read from a single SessionProvider context. Mocking that context in Storybook mocks all three simultaneously. There’s no separate flag-service mock, no separate plan mock, no separate permissions mock to keep in sync. One context, one override, full control.