Skip to main content
Search...

client libraries

Client-side access control for React, Vue, and vanilla JavaScript with server-driven permission hydration.

Overview

duck-iam provides client libraries that let you check permissions instantly in the browser. The recommended pattern is server-driven: generate a PermissionMap on the server, send it to the client, and use it for all UI decisions.

Loading diagram...

Every client library supports the same core operations:

  • can(action, resource, resourceId?, scope?) -- returns true if the permission is granted
  • cannot(action, resource, resourceId?, scope?) -- returns true if the permission is denied

No network requests happen during these checks. They are synchronous lookups against the pre-computed permission map.

The PermissionMap

A PermissionMap is a flat object where keys encode the permission and values are booleans. Keys follow one of four formats depending on which fields are present:

{
  // Format: "action:resource"
  "create:post": true,
  "delete:post": true,
  "manage:team": false,
 
  // Format: "action:resource:resourceId"
  "delete:post:abc123": false,
 
  // Format: "scope:action:resource"
  "org-1:manage:billing": true,
 
  // Format: "scope:action:resource:resourceId"
  "org-1:update:post:post-42": true,
}
{
  // Format: "action:resource"
  "create:post": true,
  "delete:post": true,
  "manage:team": false,
 
  // Format: "action:resource:resourceId"
  "delete:post:abc123": false,
 
  // Format: "scope:action:resource"
  "org-1:manage:billing": true,
 
  // Format: "scope:action:resource:resourceId"
  "org-1:update:post:post-42": true,
}

Generate it on the server using engine.permissions() or the getPermissions helper from @gentleduck/iam/server/next. When a key is not found in the map, all client libraries return false (deny by default).


React

import { createAccessControl, createPermissionChecker } from '@gentleduck/iam/client/react'

Setup

Create the access control system once at app initialization. Pass your React import to avoid a hard dependency.

// lib/access.tsx
import React from 'react'
import { createAccessControl } from '@gentleduck/iam/client/react'
 
export const {
  AccessProvider,
  useAccess,
  usePermissions,
  Can,
  Cannot,
} = createAccessControl(React)
// lib/access.tsx
import React from 'react'
import { createAccessControl } from '@gentleduck/iam/client/react'
 
export const {
  AccessProvider,
  useAccess,
  usePermissions,
  Can,
  Cannot,
} = createAccessControl(React)

AccessProvider

Wrap your app (or a subtree) with the provider. Pass the permission map generated on the server.

// app/layout.tsx (Next.js example)
import { AccessProvider } from '@/lib/access'
import { getPermissions } from '@gentleduck/iam/server/next'
 
export default async function RootLayout({ children }) {
  const session = await auth()
  const permissions = session?.user
    ? await getPermissions(engine, session.user.id, [
        { action: 'create', resource: 'post' },
        { action: 'delete', resource: 'post' },
        { action: 'manage', resource: 'team' },
        { action: 'read', resource: 'analytics' },
      ])
    : {}
 
  return (
    <AccessProvider permissions={permissions}>
      {children}
    </AccessProvider>
  )
}
// app/layout.tsx (Next.js example)
import { AccessProvider } from '@/lib/access'
import { getPermissions } from '@gentleduck/iam/server/next'
 
export default async function RootLayout({ children }) {
  const session = await auth()
  const permissions = session?.user
    ? await getPermissions(engine, session.user.id, [
        { action: 'create', resource: 'post' },
        { action: 'delete', resource: 'post' },
        { action: 'manage', resource: 'team' },
        { action: 'read', resource: 'analytics' },
      ])
    : {}
 
  return (
    <AccessProvider permissions={permissions}>
      {children}
    </AccessProvider>
  )
}

useAccess hook

Read permissions from context in any component.

import { useAccess } from '@/lib/access'
 
function PostActions({ postId }: { postId: string }) {
  const { can, cannot } = useAccess()
 
  return (
    <div>
      {can('update', 'post') && <button>Edit</button>}
      {can('delete', 'post') && <button>Delete</button>}
      {cannot('manage', 'team') && <span>Contact an admin to manage teams</span>}
    </div>
  )
}
import { useAccess } from '@/lib/access'
 
function PostActions({ postId }: { postId: string }) {
  const { can, cannot } = useAccess()
 
  return (
    <div>
      {can('update', 'post') && <button>Edit</button>}
      {can('delete', 'post') && <button>Delete</button>}
      {cannot('manage', 'team') && <span>Contact an admin to manage teams</span>}
    </div>
  )
}

The hook returns:

PropertyTypeDescription
permissionsPermissionMapThe raw permission map
can(action, resource, resourceId?, scope?) -> booleanCheck if a permission is granted
cannot(action, resource, resourceId?, scope?) -> booleanCheck if a permission is denied

Can and Cannot components

Declarative permission gates for your JSX.

import { Can, Cannot } from '@/lib/access'
 
function Dashboard() {
  return (
    <div>
      <Can action="read" resource="analytics">
        <AnalyticsPanel />
      </Can>
 
      <Can action="manage" resource="team" fallback={<UpgradePrompt />}>
        <TeamSettings />
      </Can>
 
      <Cannot action="create" resource="post">
        <p>You do not have permission to create posts.</p>
      </Cannot>
    </div>
  )
}
import { Can, Cannot } from '@/lib/access'
 
function Dashboard() {
  return (
    <div>
      <Can action="read" resource="analytics">
        <AnalyticsPanel />
      </Can>
 
      <Can action="manage" resource="team" fallback={<UpgradePrompt />}>
        <TeamSettings />
      </Can>
 
      <Cannot action="create" resource="post">
        <p>You do not have permission to create posts.</p>
      </Cannot>
    </div>
  )
}

Can props:

PropTypeRequiredDescription
actionstringyesThe action to check
resourcestringyesThe resource type to check
resourceIdstringnoSpecific resource instance
scopestringnoScope for the check
childrenReactNodeyesRendered when allowed
fallbackReactNodenoRendered when denied (defaults to null)

Cannot has the same props except fallback. It renders children when the permission is denied.

usePermissions hook

Fetch permissions from a server endpoint on the client side. Useful for SPAs that do not have server-side rendering.

import { usePermissions } from '@/lib/access'
 
function App() {
  const { can, loading, error } = usePermissions(
    () => fetch('/api/me/permissions').then(r => r.json()),
    [] // dependency array, like useEffect
  )
 
  if (loading) return <Spinner />
  if (error) return <ErrorMessage error={error} />
 
  return (
    <div>
      {can('create', 'post') && <NewPostButton />}
    </div>
  )
}
import { usePermissions } from '@/lib/access'
 
function App() {
  const { can, loading, error } = usePermissions(
    () => fetch('/api/me/permissions').then(r => r.json()),
    [] // dependency array, like useEffect
  )
 
  if (loading) return <Spinner />
  if (error) return <ErrorMessage error={error} />
 
  return (
    <div>
      {can('create', 'post') && <NewPostButton />}
    </div>
  )
}

The hook returns:

PropertyTypeDescription
permissionsPermissionMapThe fetched permission map
can(action, resource, resourceId?, scope?) -> booleanPermission checker
loadingbooleanTrue while the fetch is in progress
errorError or nullError from the fetch, if any

The hook handles race conditions internally: if the component unmounts or the dependency array changes before the fetch completes, stale results are discarded.

Standalone checker

For code outside of React components (utilities, event handlers, tests), use createPermissionChecker. It takes a PermissionMap and returns a checker object with can, cannot, and the raw permissions.

import { createPermissionChecker } from '@gentleduck/iam/client/react'
 
const checker = createPermissionChecker(permissionMap)
checker.can('delete', 'post')         // boolean
checker.cannot('manage', 'team')      // boolean
checker.permissions                    // the original PermissionMap
import { createPermissionChecker } from '@gentleduck/iam/client/react'
 
const checker = createPermissionChecker(permissionMap)
checker.can('delete', 'post')         // boolean
checker.cannot('manage', 'team')      // boolean
checker.permissions                    // the original PermissionMap

This is useful in server-side utilities, test helpers, or anywhere you need permission checks without React context.


Vue

import { createVueAccess, ACCESS_INJECTION_KEY } from '@gentleduck/iam/client/vue'

Setup

Create the Vue access control system by passing Vue's reactive utilities. This avoids a hard dependency on a specific Vue version.

// lib/access.ts
import { ref, computed, inject, provide, defineComponent, h } from 'vue'
import { createVueAccess } from '@gentleduck/iam/client/vue'
 
export const {
  useAccess,
  provideAccess,
  createAccessPlugin,
  Can,
  Cannot,
} = createVueAccess({ ref, computed, inject, provide, defineComponent, h })
// lib/access.ts
import { ref, computed, inject, provide, defineComponent, h } from 'vue'
import { createVueAccess } from '@gentleduck/iam/client/vue'
 
export const {
  useAccess,
  provideAccess,
  createAccessPlugin,
  Can,
  Cannot,
} = createVueAccess({ ref, computed, inject, provide, defineComponent, h })

Plugin installation

Install the access control plugin in your Vue app. This makes permissions available to all components.

// main.ts
import { createApp } from 'vue'
import { createAccessPlugin } from '@/lib/access'
import App from './App.vue'
 
const app = createApp(App)
 
// permissions is a PermissionMap from your server
app.use(createAccessPlugin(permissions))
 
app.mount('#app')
// main.ts
import { createApp } from 'vue'
import { createAccessPlugin } from '@/lib/access'
import App from './App.vue'
 
const app = createApp(App)
 
// permissions is a PermissionMap from your server
app.use(createAccessPlugin(permissions))
 
app.mount('#app')

The plugin also registers $can and $cannot as global properties, so you can use them directly in templates:

<button v-if="$can('delete', 'post')">Delete</button>
<button v-if="$can('delete', 'post')">Delete</button>

Composable

Use the useAccess composable in any component within the provider tree.

<script setup lang="ts">
import { useAccess } from '@/lib/access'
 
const { can, cannot, permissions, update } = useAccess()
</script>
 
<template>
  <div>
    <button v-if="can('update', 'post')">Edit</button>
    <button v-if="can('delete', 'post')">Delete</button>
    <p v-if="cannot('manage', 'team')">Contact an admin to manage teams.</p>
  </div>
</template>
<script setup lang="ts">
import { useAccess } from '@/lib/access'
 
const { can, cannot, permissions, update } = useAccess()
</script>
 
<template>
  <div>
    <button v-if="can('update', 'post')">Edit</button>
    <button v-if="can('delete', 'post')">Delete</button>
    <p v-if="cannot('manage', 'team')">Contact an admin to manage teams.</p>
  </div>
</template>

The composable returns:

PropertyTypeDescription
permissionsRef<PermissionMap>Reactive permission map
can(action, resource, resourceId?, scope?) -> booleanCheck if allowed
cannot(action, resource, resourceId?, scope?) -> booleanCheck if denied
update(newPerms) -> voidReplace the permission map (triggers reactivity)

Can and Cannot components

Declarative permission gates using slots.

<template>
  <Can action="delete" resource="post">
    <button>Delete Post</button>
  </Can>
 
  <Can action="read" resource="analytics">
    <template #default>
      <AnalyticsPanel />
    </template>
    <template #fallback>
      <p>Upgrade to Pro to access analytics.</p>
    </template>
  </Can>
 
  <Cannot action="create" resource="post">
    <p>You do not have permission to create posts.</p>
  </Cannot>
</template>
<template>
  <Can action="delete" resource="post">
    <button>Delete Post</button>
  </Can>
 
  <Can action="read" resource="analytics">
    <template #default>
      <AnalyticsPanel />
    </template>
    <template #fallback>
      <p>Upgrade to Pro to access analytics.</p>
    </template>
  </Can>
 
  <Cannot action="create" resource="post">
    <p>You do not have permission to create posts.</p>
  </Cannot>
</template>

The Can component renders the default slot when the permission is granted. If denied, it renders the fallback slot (if provided). The Cannot component renders its default slot when the permission is denied.

Manual provide/inject

If you prefer not to use the plugin, call provideAccess in a parent component. This is useful in SSR setups where you hydrate permissions per-request instead of at app startup.

<script setup lang="ts">
import { provideAccess } from '@/lib/access'
 
// permissions is a PermissionMap from your server
const state = provideAccess(permissions)
// state has the same shape as useAccess(): { permissions, can, cannot, update }
</script>
<script setup lang="ts">
import { provideAccess } from '@/lib/access'
 
// permissions is a PermissionMap from your server
const state = provideAccess(permissions)
// state has the same shape as useAccess(): { permissions, can, cannot, update }
</script>

Child components can then call useAccess() as usual.

createAccessState (low-level)

If you need full control without provide/inject, use createAccessState directly. It returns a reactive state object without registering it in the injection system.

import { createAccessState } from '@/lib/access'
 
const state = createAccessState(permissionsFromServer)
state.can('delete', 'post')      // boolean
state.permissions.value            // reactive Ref<PermissionMap>
state.update(newPermissions)       // replaces and triggers reactivity
import { createAccessState } from '@/lib/access'
 
const state = createAccessState(permissionsFromServer)
state.can('delete', 'post')      // boolean
state.permissions.value            // reactive Ref<PermissionMap>
state.update(newPermissions)       // replaces and triggers reactivity

This is returned from createVueAccess() alongside the other exports.


Vanilla (framework-agnostic)

import { AccessClient } from '@gentleduck/iam/client/vanilla'

Basic usage

Create an AccessClient from a server-provided permission map.

import { AccessClient } from '@gentleduck/iam/client/vanilla'
 
const access = new AccessClient(permissionsFromServer)
 
access.can('delete', 'post')                     // true
access.cannot('manage', 'billing')                // true
access.can('manage', 'user', undefined, 'admin')  // scoped check
import { AccessClient } from '@gentleduck/iam/client/vanilla'
 
const access = new AccessClient(permissionsFromServer)
 
access.can('delete', 'post')                     // true
access.cannot('manage', 'billing')                // true
access.can('manage', 'user', undefined, 'admin')  // scoped check

Fetch from server

Create a client that fetches its permissions from an API endpoint. The fromServer static method sends a GET request, parses the JSON response as a PermissionMap, and returns a new AccessClient. It throws an error if the response is not OK (non-2xx status).

const access = await AccessClient.fromServer('/api/me/permissions', {
  headers: { Authorization: 'Bearer ' + token },
})
 
access.can('delete', 'post') // boolean
const access = await AccessClient.fromServer('/api/me/permissions', {
  headers: { Authorization: 'Bearer ' + token },
})
 
access.can('delete', 'post') // boolean

The second argument is a standard RequestInit -- you can pass headers, credentials, signal for abort, etc. A Content-Type: application/json header is added automatically.

Allowed actions

Query all actions the user can perform on a given resource. allowedActions scans the permission map and returns a deduplicated array of action strings where the value is true. hasAnyOn is a faster short-circuit check that returns as soon as it finds any allowed permission.

access.allowedActions('post')  // ['read', 'create', 'update']
access.hasAnyOn('billing')     // false
access.hasAnyOn('post')        // true
access.allowedActions('post')  // ['read', 'create', 'update']
access.hasAnyOn('billing')     // false
access.hasAnyOn('post')        // true

Both methods handle all four key formats. For a 3-part key like "org-1:read:post", the method correctly identifies "post" as the resource and "read" as the action.

Reactive updates

Subscribe to permission changes to trigger re-renders in any framework.

const unsubscribe = access.subscribe((newPermissions) => {
  // Re-render your UI with the new permissions
  rerender()
})
 
// Update permissions (e.g. after role change)
access.update(newPermissionsFromServer)
 
// Merge new permissions into existing ones
access.merge({ 'manage:team': true })
 
// Clean up
unsubscribe()
const unsubscribe = access.subscribe((newPermissions) => {
  // Re-render your UI with the new permissions
  rerender()
})
 
// Update permissions (e.g. after role change)
access.update(newPermissionsFromServer)
 
// Merge new permissions into existing ones
access.merge({ 'manage:team': true })
 
// Clean up
unsubscribe()

Integration with other frameworks

The vanilla client works as the foundation for any framework.

Svelte:

// stores/access.ts
import { writable, derived } from 'svelte/store'
import { AccessClient } from '@gentleduck/iam/client/vanilla'
 
const client = new AccessClient(initialPermissions)
const permissions = writable(client.permissions)
 
client.subscribe((perms) => permissions.set(perms))
 
export const can = (action: string, resource: string) =>
  derived(permissions, () => client.can(action, resource))
// stores/access.ts
import { writable, derived } from 'svelte/store'
import { AccessClient } from '@gentleduck/iam/client/vanilla'
 
const client = new AccessClient(initialPermissions)
const permissions = writable(client.permissions)
 
client.subscribe((perms) => permissions.set(perms))
 
export const can = (action: string, resource: string) =>
  derived(permissions, () => client.can(action, resource))

Solid:

import { createSignal } from 'solid-js'
import { AccessClient } from '@gentleduck/iam/client/vanilla'
 
const client = new AccessClient(initialPermissions)
const [permissions, setPermissions] = createSignal(client.permissions)
 
client.subscribe(setPermissions)
 
export const can = (action: string, resource: string) =>
  () => client.can(action, resource)
import { createSignal } from 'solid-js'
import { AccessClient } from '@gentleduck/iam/client/vanilla'
 
const client = new AccessClient(initialPermissions)
const [permissions, setPermissions] = createSignal(client.permissions)
 
client.subscribe(setPermissions)
 
export const can = (action: string, resource: string) =>
  () => client.can(action, resource)

AccessClient API reference

Method / PropertyTypeDescription
constructor(perms?)new (PermissionMap?) -> AccessClientCreate from a permission map
AccessClient.fromServer(url, init?)static async -> AccessClientFetch permissions from an endpoint
can(action, resource, resourceId?, scope?)-> booleanCheck if allowed
cannot(action, resource, resourceId?, scope?)-> booleanCheck if denied
allowedActions(resource)-> string[]List all allowed actions for a resource
hasAnyOn(resource)-> booleanCheck if any permission exists on a resource
permissionsReadonly<PermissionMap>Current permission map (read-only)
update(perms)-> voidReplace permissions and notify listeners
merge(perms)-> voidMerge new permissions into existing and notify
subscribe(fn)-> () -> voidListen for changes, returns unsubscribe function

Server-driven pattern

The recommended architecture for production applications:

Loading diagram...

This pattern has several advantages:

  • Security: Permission logic runs on the server where policies and roles are stored. The client never sees the rules or conditions -- only the final boolean results.
  • Performance: Client-side checks are instant object lookups. No async operations, no network requests, no engine evaluation.
  • Consistency: The server is the single source of truth. The client reflects exactly what the server decided.
  • Simplicity: The client libraries are thin wrappers around a flat object. There is no complex state to manage.

Refreshing permissions

When a user's role changes (e.g. they are promoted, join an org, or a feature flag flips), fetch a new permission map from the server and update the client.

React:

// Re-render the server component (Next.js router.refresh())
// or use usePermissions with a refetch trigger
// Re-render the server component (Next.js router.refresh())
// or use usePermissions with a refetch trigger

Vue:

const { update } = useAccess()
const newPerms = await fetch('/api/me/permissions').then(r => r.json())
update(newPerms)
const { update } = useAccess()
const newPerms = await fetch('/api/me/permissions').then(r => r.json())
update(newPerms)

Vanilla:

const newPerms = await fetch('/api/me/permissions').then(r => r.json())
access.update(newPerms) // subscribers are notified automatically
const newPerms = await fetch('/api/me/permissions').then(r => r.json())
access.update(newPerms) // subscribers are notified automatically