npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vortos/permissions

v1.2.0

Published

React permissions provider, hooks, and components for Vortos applications

Downloads

59

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/permissions

React is a peer dependency:

react >= 18

Basic 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.