@vortos/permissions
v1.2.0
Published
React permissions provider, hooks, and components for Vortos applications
Downloads
59
Maintainers
Readme
@vortos/permissions
React permissions provider, hooks, and components for Vortos applications.
The backend decides what the current user can do. This package keeps the frontend permission state local, observable, refreshable, and easy to consume from React components.
Backend decides.
Frontend remembers.
Components ask locally.
Backend still enforces.Install
npm install @vortos/permissionsReact is a peer dependency:
react >= 18Basic Setup
Wrap your app once near the router:
import { PermissionsProvider } from '@vortos/permissions';
export function App() {
return (
<PermissionsProvider endpoint="/api/me/permissions">
<Router />
</PermissionsProvider>
);
}The endpoint can be any route. /api/me/permissions is only the default Vortos convention.
<PermissionsProvider endpoint="/internal/session/permissions">
<App />
</PermissionsProvider>Response Contract
Minimum response:
{
"permissions": ["ROLE_ADMIN", "athletes.update.any"]
}Enterprise response with metadata:
{
"permissions": ["athletes.update.own"],
"roles": ["ROLE_COACH"],
"scopes": {
"federationId": "fed_123",
"teamIds": ["team_7", "team_9"]
},
"version": "perm_2026_05_04_001"
}permissions drives UI checks. roles, scopes, and version are available through the stateful API for debugging, audit, tenant-aware UI, and observability.
Simple Hooks
import {
usePermission,
usePermissions,
useAnyPermission,
useAllPermissions,
} from '@vortos/permissions';
const canEdit = usePermission('athletes.update.own');
const permissions = usePermissions();
const isPrivileged = useAnyPermission('ROLE_ADMIN', 'ROLE_SUPER_ADMIN');
const canSeeAnalytics = useAllPermissions('reports.read.any', 'analytics.view.any');These hooks are intentionally simple and return only booleans or arrays.
Stateful Hooks
Use usePermissionsState() when a screen needs loading, stale, refresh, or error state:
import { usePermissionsState } from '@vortos/permissions';
function PermissionPanel() {
const {
permissions,
roles,
scopes,
version,
loading,
refreshing,
stale,
error,
refetch,
has,
hasAny,
hasAll,
} = usePermissionsState();
if (loading) return <Spinner />;
if (error) return <RetryPanel error={error} onRetry={refetch} />;
return (
<button disabled={refreshing} onClick={() => refetch()}>
Refresh permissions
</button>
);
}For one permission plus state:
import { usePermissionState } from '@vortos/permissions';
function DeletePostButton() {
const { allowed, loading, error, refetch } = usePermissionState('posts.delete.any');
if (loading) return <button disabled>Loading</button>;
if (error) return <button onClick={() => refetch()}>Retry</button>;
return <button disabled={!allowed}>Delete</button>;
}Components
Use Can for conditional UI:
import { Can } from '@vortos/permissions';
<Can permission="billing.manage">
<BillingSettings />
</Can>With fallback:
<Can permission="billing.manage" fallback={<ReadOnlyBillingNotice />}>
<BillingSettings />
</Can>Disable unavailable actions instead of hiding them:
<Can
permission="invoices.refund.any"
fallbackMode="disable"
deniedReason="You need the invoices.refund.any permission."
>
<button>Refund invoice</button>
</Can>Use RequirePermission for route guards:
import { RequirePermission } from '@vortos/permissions';
export function AdminRoute() {
return (
<RequirePermission
permission="admin.dashboard.view"
loadingFallback={<PageSpinner />}
fallback={<Navigate to="/" replace />}
>
<AdminDashboard />
</RequirePermission>
);
}Access Policy
Can and RequirePermission work, but they scatter permission strings and fallback decisions across every call site. The access policy centralises both: one map decides what happens when a permission is missing, keyed by a stable target id.
import type { AccessPolicy } from '@vortos/permissions';
export const accessPolicy = {
'nav.payments': { permission: 'payments.view.any' },
'page.payments': { permission: 'payments.view.any', mode: 'block',
message: 'Your role does not include payment access.' },
'action.payment.waive': { permission: 'payments.waive.any', mode: 'disable',
message: 'Only finance admins can waive a fee.' },
'action.payment.export': { anyOf: ['payments.export.any', 'reports.export.any'] },
'section.settings.billing': { permission: 'billing.manage', mode: 'readonly' },
'page.admin': { permission: 'admin.*', mode: 'redirect', redirectTo: '/' },
} satisfies AccessPolicy;Hand it to the provider once:
<PermissionsProvider
endpoint="/api/me/permissions"
access={{
policy: accessPolicy,
defaultMode: 'hide',
defaultMessage: 'You do not have permission to do this.',
renderBlocked: ({ message }) => <NoAccessPage message={message} />,
onRedirect: (to) => router.navigate(to, { replace: true }),
disabledClassName: 'opacity-50 cursor-not-allowed',
}}
>
<Router />
</PermissionsProvider>Keep the access object referentially stable — a module-level const, or useMemo — otherwise the config is rebuilt on every render.
Modes
| Mode | Effect when the check fails |
| --- | --- |
| hide | Renders nothing (or renderHidden). The default. |
| disable | Children render inert, with message as the tooltip. |
| readonly | Children render inside a read-only context; controls call useReadonly() to switch to their non-editable variant. |
| block | Replaces the subtree with renderBlocked({ message }) — for whole pages. |
| redirect | Calls onRedirect(redirectTo) and renders nothing. |
Gating UI
import { Gate } from '@vortos/permissions';
// One button — hidden, disabled or read-only purely by what the policy says
<Gate id="action.payment.waive">
<Button onClick={waive}>Waive fee</Button>
</Gate>
// A whole page
<Gate id="page.payments">
<PaymentsPage />
</Gate>
// Override the policy for one placement only
<Gate id="action.payment.waive" mode="hide">
<MenuItem>Waive fee</MenuItem>
</Gate>
// One-off check with no policy entry
<Gate permission="reports.export.any" mode="disable" message="Ask an admin.">
<Button>Export</Button>
</Gate>fallback still wins over the mode when you want something bespoke:
<Gate id="section.settings.billing" fallback={<UpgradePrompt />}>
<BillingSettings />
</Gate>Imperative checks
import { useGate } from '@vortos/permissions';
const { allowed, mode, message, loading } = useGate('action.payment.waive');
<Button disabled={!allowed} title={allowed ? undefined : message}>
Waive fee
</Button>Filtering lists
Navigation, tabs, table columns, and menu actions come from arrays, not JSX:
import { useAccessFilter } from '@vortos/permissions';
const filterByAccess = useAccessFilter();
const visibleItems = filterByAccess(navigationItems, (item) => item.accessId);Entries whose rule resolves to disable or readonly are kept — those modes mean "render it differently", not "remove it".
Read-only subtrees
import { useReadonly } from '@vortos/permissions';
function NameField() {
const readonly = useReadonly();
return readonly ? <span>{value}</span> : <input value={value} onChange={onChange} />;
}Wildcards
Rules and grants both accept *, matched in either direction — a rule requiring payments.* is satisfied by payments.waive.any, and a grant of payments.* satisfies a rule requiring payments.waive.any.
Failure behaviour
Gates fail closed. While the permission set is loading, nothing is allowed — loadingFallback renders instead. A gate referencing an id that is not in the policy falls back to unknownIdMode (default hide) and warns once in the console, so a typo hides a control rather than exposing one.
Set enforced: false on a rule to neutralise it during staged rollout without deleting it.
Auth Headers
Headers are part of the provider's refetch identity. If a token changes, permissions refetch. The provider does not manage login, token storage, or token refresh. Read the access token from your app's auth/session layer and pass it as a header.
For a Vortos JWT login response, the access token is returned as token.access_token:
const loginResponse = await login(email, password);
const accessToken = loginResponse.token.access_token;<PermissionsProvider
endpoint="/api/me/permissions"
headers={{ Authorization: `Bearer ${accessToken}` }}
>
<Router />
</PermissionsProvider>Tokens That Expire
A header object is a snapshot. The provider polls on its own schedule, so if that object holds a short-lived access token, every poll after the token's expiry returns 401 — and keeps returning 401 until something re-renders the provider with a new one. Pass a function instead and the provider resolves it on each attempt, picking up whatever your auth layer currently holds:
<PermissionsProvider
endpoint="/api/me/permissions"
headers={() => ({ Authorization: `Bearer ${getAccessToken()}` })}
onUnauthorized={() => silentRefresh()}
refreshInterval={120_000}
>
<Router />
</PermissionsProvider>onUnauthorized is the backstop for the narrow case where a token expires between two
polls: on a 401 or 403 the provider calls it once, and if it resolves true retries the
request with freshly resolved headers. Return false when the credentials cannot be
renewed and the original error surfaces as normal. It is asked at most once per request,
so a handler that returns true without renewing costs one wasted attempt, not a loop.
Errors reaching onError are HttpError instances carrying status whenever the server
responded, so callers can branch on the status rather than parsing a message.
Refreshing, Stale State, And Cache
<PermissionsProvider
endpoint="/api/me/permissions"
headers={{ Authorization: `Bearer ${accessToken}` }}
staleTime={30_000}
refreshInterval={60_000}
refetchOnWindowFocus
retries={2}
retryDelayMs={500}
persist
cacheKey={`permissions:${userId}:${tenantId}`}
>
<Router />
</PermissionsProvider>Use tenant-aware cache keys:
permissions:${userId}:${tenantId}This prevents one user's cached permissions from appearing after another user logs in on the same browser.
SSR Initial Data
<PermissionsProvider
initialPermissions={serverPermissions}
initialRoles={serverRoles}
initialScopes={serverScopes}
initialVersion={serverPermissionVersion}
>
<App />
</PermissionsProvider>The provider still refetches on the client after mount.
Observability
<PermissionsProvider
endpoint="/api/me/permissions"
onError={(error) => logger.capture(error)}
onUpdate={(state) => {
analytics.track('permissions.updated', {
count: state.permissions.length,
version: state.version,
stale: state.stale,
});
}}
>
<Router />
</PermissionsProvider>Security Boundary
Frontend permission checks are UX only. Every protected API route, command, query, and controller must still enforce authorization on the backend.
