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

@grantjs/client

v1.0.1

Published

Browser SDK for Grant authorization - React hooks and components for permission-based UI rendering

Readme

@grantjs/client

Browser SDK for Grant authorization platform. Provides a lightweight client for permission checks with React hooks and components for conditional UI rendering.

Documentation: Client SDK in the official docs.

Features

  • REST-based API - Uses native fetch, no GraphQL client required
  • Automatic token refresh - Handles 401 errors with token refresh and retry
  • Built-in caching - Configurable TTL-based cache to minimize API calls
  • Multi-tenant support - Dynamic scope switching for session tokens
  • React integration - Hooks and components for declarative permission checks
  • TypeScript - Full type safety with types from @grantjs/schema

Installation

npm install @grantjs/client
# or
pnpm add @grantjs/client
# or
yarn add @grantjs/client

Quick Start

1. Create the Client

import { GrantClient } from '@grantjs/client';

const grant = new GrantClient({
  apiUrl: 'https://api.your-app.com',

  getAccessToken: () => localStorage.getItem('accessToken'),

  // Cookie-based refresh: your callback calls POST /api/auth/refresh with credentials: 'include',
  // then updates app token storage. Refresh token is in HttpOnly cookie; response body has only accessToken.
  onRefreshWithCredentials: async () => {
    const res = await fetch('https://api.your-app.com/api/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    });
    if (!res.ok) return false;
    const { data } = await res.json();
    if (data?.accessToken) {
      localStorage.setItem('accessToken', data.accessToken);
      return true;
    }
    return false;
  },

  onTokenRefresh: (tokens) => {
    localStorage.setItem('accessToken', tokens.accessToken);
    // tokens.refreshToken may be undefined when using cookie-based refresh
  },

  onUnauthorized: () => {
    window.location.href = '/login';
  },
});

// Check permission
const canEdit = await grant.can('Document', 'Update');

2. React Setup

Wrap your app with GrantProvider and integrate with your auth store:

'use client';

import { useMemo } from 'react';
import { GrantProvider, type GrantClientConfig } from '@grantjs/client/react';
import { useAuthStore } from '@/stores/auth.store';

export function AppProviders({ children }: { children: React.ReactNode }) {
  const config = useMemo<GrantClientConfig>(
    () => ({
      apiUrl: process.env.NEXT_PUBLIC_API_URL!,

      getAccessToken: () => useAuthStore.getState().accessToken,

      // Cookie-based refresh: POST /api/auth/refresh with credentials: 'include', then update store
      onRefreshWithCredentials: async () => {
        const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refresh`, {
          method: 'POST',
          credentials: 'include',
        });
        if (!res.ok) return false;
        const { data } = await res.json();
        if (data?.accessToken) {
          useAuthStore.getState().setAccessToken(data.accessToken);
          return true;
        }
        return false;
      },

      onTokenRefresh: (tokens) => {
        useAuthStore.getState().setAccessToken(tokens.accessToken);
      },

      onUnauthorized: () => {
        useAuthStore.getState().logout();
        if (typeof window !== 'undefined') {
          window.location.href = '/auth/login';
        }
      },

      cache: {
        ttl: 5 * 60 * 1000, // 5 minutes
        prefix: 'grant',
      },
    }),
    []
  );

  return <GrantProvider config={config}>{children}</GrantProvider>;
}

3. Use useGrant with Scopes

For multi-tenant apps, pass the scope to check permissions in a specific context:

'use client';

import { useGrant } from '@grantjs/client/react';
import { Tenant } from '@grantjs/schema';

interface OrganizationActionsProps {
  organization: { id: string; name: string };
}

export function OrganizationActions({ organization }: OrganizationActionsProps) {
  // Scope permissions to this specific organization
  const scope = { tenant: Tenant.Organization, id: organization.id };

  // Check permissions - these call POST /api/auth/is-authorized
  const canUpdate = useGrant('Organization', 'Update', { scope });
  const canDelete = useGrant('Organization', 'Delete', { scope });

  // Hide component entirely if user has no permissions
  if (!canUpdate && !canDelete) {
    return null;
  }

  return (
    <div>
      {canUpdate && <button>Edit</button>}
      {canDelete && <button>Delete</button>}
    </div>
  );
}

4. Use Components

import { GrantGate } from '@grantjs/client/react';
import { Tenant } from '@grantjs/schema';

function Dashboard({ projectId }: { projectId: string }) {
  const scope = { tenant: Tenant.Organization, id: projectId };

  return (
    <div>
      {/* Hide if no permission */}
      <GrantGate resource="Analytics" action="Read" scope={scope}>
        <AnalyticsWidget />
      </GrantGate>

      {/* Show fallback if denied */}
      <GrantGate
        resource="Settings"
        action="Update"
        scope={scope}
        fallback={<p>Contact admin for access</p>}
      >
        <SettingsPanel />
      </GrantGate>

      {/* With loading state */}
      <GrantGate resource="Report" action="Create" scope={scope} loading={<Spinner />}>
        <ExportButton />
      </GrantGate>
    </div>
  );
}

API Reference

GrantClient

const grant = new GrantClient(config: GrantClientConfig);

Configuration

interface GrantClientConfig {
  apiUrl: string;

  getAccessToken?: () => string | null | Promise<string | null>;
  /** Called after cookie-based refresh; use to update app token storage. `tokens.refreshToken` may be undefined. */
  onTokenRefresh?: (tokens: AuthTokens) => void | Promise<void>;
  onUnauthorized?: () => void;
  /** Cookie-based refresh on 401: call POST /api/auth/refresh with credentials: 'include', update token, return true on success. */
  onRefreshWithCredentials?: () => Promise<boolean>;

  fetch?: typeof fetch;
  credentials?: RequestCredentials; // default: 'include'
  cache?: { ttl?: number; prefix?: string };
}

Methods

// Permission checks
grant.can(resource, action, options?): Promise<boolean>
grant.hasPermission(resource, action, options?): Promise<boolean>  // Alias
grant.isAuthorized(resource, action, options?): Promise<AuthorizationResult>

// Cache management
grant.clearCache(): void
grant.clearScopeCache(scope?): void

React Hooks

useGrant(resource, action, options?)

Returns a boolean by default, or an object with isGranted and isLoading when returnLoading: true.

Default (boolean):

import { useGrant } from '@grantjs/client/react';
import { Tenant } from '@grantjs/schema';

const canEdit = useGrant('Document', 'Update', {
  scope: { tenant: Tenant.Organization, id: orgId },
});

return <div>{canEdit && <EditButton />}</div>;

With loading state:

const { isGranted, isLoading } = useGrant('Document', 'Update', {
  scope: { tenant: Tenant.Organization, id: orgId },
  returnLoading: true,
});

if (isLoading) return <Spinner />;
if (!isGranted) return null;

return <EditButton />;

Hook Options

interface UseGrantOptions {
  scope?: Scope; // Multi-tenant scope override
  enabled?: boolean; // Skip check if false (default: true)
  useCache?: boolean; // Use cached result (default: true)
  returnLoading?: boolean; // Return object with isGranted and isLoading (default: false)
}

interface UseGrantResult {
  isGranted: boolean; // Whether the user is granted permission
  isLoading: boolean; // Whether the permission check is loading
}

React Components

<GrantGate>

<GrantGate
  resource="Resource"
  action="Action"
  scope={{ tenant: Tenant.Organization, id: '...' }} // Optional
  fallback={<FallbackComponent />} // Optional
  loading={<LoadingSpinner />} // Optional
>
  <ProtectedContent />
</GrantGate>

Multi-Tenant Scope Override

The Grant platform supports multi-tenant authorization with dynamic scope switching:

  • Session tokens: Can override scope at request time (for users switching between organizations)
  • API key tokens: Use their embedded scope (cannot be overridden)
import { useGrant } from '@grantjs/client/react';
import { Tenant } from '@grantjs/schema';

// User is viewing Organization A
const scopeA = { tenant: Tenant.Organization, id: 'org-a-id' };
const canEditA = useGrant('Project', 'Update', { scope: scopeA });

// User switches to Organization B
const scopeB = { tenant: Tenant.Organization, id: 'org-b-id' };
const canEditB = useGrant('Project', 'Update', { scope: scopeB });

Available tenant types (from @grantjs/schema):

enum Tenant {
  System = 'system',
  Account = 'account',
  Organization = 'organization',
  AccountProject = 'accountProject',
  OrganizationProject = 'organizationProject',
  ProjectUser = 'projectUser',
  // ... and more
}

Caching

The client caches permission results by default (5 minute TTL). You can:

// Configure TTL
const grant = new GrantClient({
  apiUrl: '...',
  cache: { ttl: 10 * 60 * 1000 }, // 10 minutes
});

// Bypass cache for a specific check
const fresh = await grant.can('Resource', 'Action', { useCache: false });

// Clear all cache
grant.clearCache();

// Clear cache for a specific scope
grant.clearScopeCache({ tenant: Tenant.Organization, id: orgId });

Authentication Flow

  1. Access token is sent via Authorization: Bearer <token> (when getAccessToken is provided).
  2. Cookies are included by default (credentials: 'include').
  3. On 401, the client uses cookie-based refresh only (no body-based refresh for security):
    • If onRefreshWithCredentials is set, it is called. Your callback should call POST /api/auth/refresh with credentials: 'include', read the new accessToken from the response body, update your storage, and return true on success.
    • On success the client retries the original request; you may also use onTokenRefresh to sync token state.
    • On failure or if onRefreshWithCredentials is not set, onUnauthorized() is called (e.g. redirect to login).
    • The refresh token lives in an HttpOnly cookie; the API returns only accessToken in the refresh response body.

Development Notes

React Strict Mode

In development with React Strict Mode enabled (default in Next.js 13+), you'll see 2 API calls per permission check. This is expected behavior:

  1. Component mounts → effect runs → API call #1
  2. Strict Mode unmounts component
  3. Component remounts → effect runs → API call #2

This only happens in development. Production builds make a single call.

Stable Scope References

The hooks automatically handle scope object reference changes. You don't need to memoize the scope object:

// This is fine - hooks compare by value, not reference
const scope = { tenant: Tenant.Organization, id: organization.id };
const canEdit = useGrant('Resource', 'Action', { scope });

TypeScript

Full type definitions are included. Import types from the package or re-exported from @grantjs/schema:

import type {
  GrantClientConfig,
  AuthorizationResult,
  Permission,
  Resource,
  Scope,
  Tenant,
} from '@grantjs/client';

// Or import Tenant enum directly from schema
import { Tenant } from '@grantjs/schema';

Contributing

Contributions are welcome! Please see the main repository for contribution guidelines.

Support

License

MIT