JSPM

@grantjs/client

1.0.1
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 10
  • Score
    100M100P100Q63727F
  • License MIT

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

Package Exports

  • @grantjs/client
  • @grantjs/client/react

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