Skip to main content
Search...

chapter 7: client libraries

Use permission maps in the browser to show and hide UI elements. Integrate with React, Vue, or vanilla JavaScript for real-time permission-based rendering.

Goal

BlogDuck's server is protected. Now you need the UI to respect permissions too -- hide the delete button if the user cannot delete, show the edit form only for editors. This chapter covers the three client integrations: React, Vue, and vanilla JavaScript.

Loading diagram...

The PermissionMap

A PermissionMap is a simple key-value object generated on the server:

{
  "create:post": true,
  "update:post:post-1": true,
  "delete:post:post-1": false,
  "manage:dashboard": false,
  "acme:manage:user": true,    // scoped permission
}
{
  "create:post": true,
  "update:post:post-1": true,
  "delete:post:post-1": false,
  "manage:dashboard": false,
  "acme:manage:user": true,    // scoped permission
}

PermissionKey Format

Keys are colon-separated strings built by the buildPermissionKey() function:

type PermissionKey =
  | `${action}:${resource}`                      // create:post
  | `${action}:${resource}:${resourceId}`        // update:post:post-1
  | `${scope}:${action}:${resource}`             // acme:manage:user
  | `${scope}:${action}:${resource}:${resourceId}` // acme:update:post:post-1
type PermissionKey =
  | `${action}:${resource}`                      // create:post
  | `${action}:${resource}:${resourceId}`        // update:post:post-1
  | `${scope}:${action}:${resource}`             // acme:manage:user
  | `${scope}:${action}:${resource}:${resourceId}` // acme:update:post:post-1
type PermissionMap = Record<PermissionKey, boolean>
type PermissionMap = Record<PermissionKey, boolean>

The server generates this map using engine.permissions() or generatePermissionMap(), then sends it to the client as JSON. The client never calls the engine directly -- it just reads the map. This makes client-side checks O(1) key lookups.

React

Create the access control system

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

createAccessControl takes the React instance to avoid a hard dependency on React. This keeps duck-iam tree-shakeable -- if you only use the vanilla client, React is never bundled.

What it returns:

ExportTypeDescription
AccessProviderComponentContext provider, wraps your app
useAccess()HookReturns { permissions, can, cannot }
CanComponentRenders children when permission is granted
CannotComponentRenders children when permission is denied
AccessContextReact.ContextRaw context for advanced use cases

Wrap your app with the provider

src/app/layout.tsx
import { AccessProvider } from '@/lib/access-client'
import { getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
 
export default async function Layout({ children }) {
  const userId = 'alice'  // from your auth
  const permissions = await getPermissions(engine, userId, [
    { action: 'create', resource: 'post' },
    { action: 'manage', resource: 'dashboard' },
    { action: 'manage', resource: 'user', scope: 'acme' },
  ])
 
  return (
    <AccessProvider permissions={permissions}>
      {children}
    </AccessProvider>
  )
}
src/app/layout.tsx
import { AccessProvider } from '@/lib/access-client'
import { getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/access'
 
export default async function Layout({ children }) {
  const userId = 'alice'  // from your auth
  const permissions = await getPermissions(engine, userId, [
    { action: 'create', resource: 'post' },
    { action: 'manage', resource: 'dashboard' },
    { action: 'manage', resource: 'user', scope: 'acme' },
  ])
 
  return (
    <AccessProvider permissions={permissions}>
      {children}
    </AccessProvider>
  )
}

The provider makes permissions available to all child components via React context. The can() function is memoized internally for performance.

Use the hook for programmatic checks

src/components/post-actions.tsx
'use client'
import { useAccess } from '@/lib/access-client'
 
export function PostActions({ postId }: { postId: string }) {
  const { can, cannot, permissions } = useAccess()
 
  return (
    <div>
      {can('update', 'post', postId) && (
        <button>Edit</button>
      )}
      {can('delete', 'post', postId) && (
        <button>Delete</button>
      )}
      {cannot('update', 'post', postId) && (
        <span>Read only</span>
      )}
    </div>
  )
}
src/components/post-actions.tsx
'use client'
import { useAccess } from '@/lib/access-client'
 
export function PostActions({ postId }: { postId: string }) {
  const { can, cannot, permissions } = useAccess()
 
  return (
    <div>
      {can('update', 'post', postId) && (
        <button>Edit</button>
      )}
      {can('delete', 'post', postId) && (
        <button>Delete</button>
      )}
      {cannot('update', 'post', postId) && (
        <span>Read only</span>
      )}
    </div>
  )
}

useAccess() return value:

interface AccessContextValue {
  permissions: PermissionMap    // the raw permission map
  can(action, resource, resourceId?, scope?): boolean
  cannot(action, resource, resourceId?, scope?): boolean
}
interface AccessContextValue {
  permissions: PermissionMap    // the raw permission map
  can(action, resource, resourceId?, scope?): boolean
  cannot(action, resource, resourceId?, scope?): boolean
}

can() and cannot() are synchronous -- they do a key lookup in the permission map.

Use declarative components

src/components/toolbar.tsx
'use client'
import { Can, Cannot } from '@/lib/access-client'
 
export function Toolbar() {
  return (
    <nav>
      <Can action="create" resource="post">
        <button>New Post</button>
      </Can>
 
      <Can action="manage" resource="dashboard" fallback={<span>View only</span>}>
        <a href="/admin">Admin Panel</a>
      </Can>
 
      <Cannot action="create" resource="post">
        <p>You do not have permission to create posts.</p>
      </Cannot>
    </nav>
  )
}
src/components/toolbar.tsx
'use client'
import { Can, Cannot } from '@/lib/access-client'
 
export function Toolbar() {
  return (
    <nav>
      <Can action="create" resource="post">
        <button>New Post</button>
      </Can>
 
      <Can action="manage" resource="dashboard" fallback={<span>View only</span>}>
        <a href="/admin">Admin Panel</a>
      </Can>
 
      <Cannot action="create" resource="post">
        <p>You do not have permission to create posts.</p>
      </Cannot>
    </nav>
  )
}

Can component props:

PropTypeDescription
actionstringRequired action
resourcestringRequired resource type
resourceIdstring?Optional resource instance
scopestring?Optional scope
childrenReactNodeRendered when permission is granted
fallbackReactNode?Rendered when permission is denied

Cannot has the same props except no fallback -- it only renders when denied.

Fetch permissions dynamically (SPA pattern)

For SPAs that do not use server-side rendering:

src/components/app.tsx
import { usePermissions } from '@gentleduck/iam/client/react'
 
function App() {
  const { permissions, can, loading, error } = usePermissions(
    () => fetch('/api/permissions').then(r => r.json()),
    []  // dependency array -- re-fetch when deps change
  )
 
  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
 
  return (
    <div>
      {can('create', 'post') && <button>New Post</button>}
    </div>
  )
}
src/components/app.tsx
import { usePermissions } from '@gentleduck/iam/client/react'
 
function App() {
  const { permissions, can, loading, error } = usePermissions(
    () => fetch('/api/permissions').then(r => r.json()),
    []  // dependency array -- re-fetch when deps change
  )
 
  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
 
  return (
    <div>
      {can('create', 'post') && <button>New Post</button>}
    </div>
  )
}

usePermissions() signature:

function usePermissions(
  fetchFn: () => Promise<PermissionMap>,
  deps?: any[],
): {
  permissions: PermissionMap
  can(action, resource, resourceId?, scope?): boolean
  loading: boolean
  error: Error | null
}
function usePermissions(
  fetchFn: () => Promise<PermissionMap>,
  deps?: any[],
): {
  permissions: PermissionMap
  can(action, resource, resourceId?, scope?): boolean
  loading: boolean
  error: Error | null
}

The hook fetches on mount and whenever deps change. While loading, can() returns false for all checks (safe default). If the fetch fails, error is set and can() still returns false.

Standalone Permission Checker

If you have a permission map but do not need React context:

import { createPermissionChecker } from '@gentleduck/iam/client/react'
 
const { can, cannot, permissions } = createPermissionChecker(permissionMap)
 
if (can('delete', 'post', 'post-1')) {
  // render delete button
}
import { createPermissionChecker } from '@gentleduck/iam/client/react'
 
const { can, cannot, permissions } = createPermissionChecker(permissionMap)
 
if (can('delete', 'post', 'post-1')) {
  // render delete button
}

This works in any environment -- server, client, tests, or Node.js scripts. No React context or provider needed.

Vue 3

Create the access system

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

Like React, Vue utilities are passed as a parameter to avoid a hard dependency.

What it returns:

ExportTypeDescription
provideAccess(perms)FunctionProvides access state to component tree
useAccess()ComposableReturns { permissions, can, cannot, update }
createAccessPlugin(perms)Vue PluginGlobal plugin with $can and $cannot
createAccessState(perms)FunctionLow-level reactive state factory
CanComponentRenders slot when permission granted
CannotComponentRenders slot when permission denied
ACCESS_INJECTION_KEYSymbolFor advanced provide/inject scenarios

Install as a plugin

src/main.ts
import { createApp } from 'vue'
import { createAccessPlugin } from '@/lib/access-client'
import App from './App.vue'
 
const permissions = await fetch('/api/permissions').then(r => r.json())
 
const app = createApp(App)
app.use(createAccessPlugin(permissions))
app.mount('#app')
src/main.ts
import { createApp } from 'vue'
import { createAccessPlugin } from '@/lib/access-client'
import App from './App.vue'
 
const permissions = await fetch('/api/permissions').then(r => r.json())
 
const app = createApp(App)
app.use(createAccessPlugin(permissions))
app.mount('#app')

The plugin makes $can and $cannot available on all component instances via this.$can('delete', 'post') in the Options API.

Use provide/inject (alternative)

src/App.vue
<script setup>
import { provideAccess } from '@/lib/access-client'
 
const props = defineProps<{ permissions: PermissionMap }>()
const { can, cannot, update } = provideAccess(props.permissions)
</script>
src/App.vue
<script setup>
import { provideAccess } from '@/lib/access-client'
 
const props = defineProps<{ permissions: PermissionMap }>()
const { can, cannot, update } = provideAccess(props.permissions)
</script>

provideAccess() returns reactive access state and provides it to all descendants. The update(newPerms) function replaces the permissions reactively.

Use the composable

src/components/PostActions.vue
<script setup>
import { useAccess } from '@/lib/access-client'
 
const props = defineProps<{ postId: string }>()
const { can, cannot } = useAccess()
</script>
 
<template>
  <div>
    <button v-if="can('update', 'post', props.postId)">Edit</button>
    <button v-if="can('delete', 'post', props.postId)">Delete</button>
    <span v-if="cannot('update', 'post', props.postId)">Read only</span>
  </div>
</template>
src/components/PostActions.vue
<script setup>
import { useAccess } from '@/lib/access-client'
 
const props = defineProps<{ postId: string }>()
const { can, cannot } = useAccess()
</script>
 
<template>
  <div>
    <button v-if="can('update', 'post', props.postId)">Edit</button>
    <button v-if="can('delete', 'post', props.postId)">Delete</button>
    <span v-if="cannot('update', 'post', props.postId)">Read only</span>
  </div>
</template>

useAccess() return value:

{
  permissions: Ref<PermissionMap>  // reactive
  can(action, resource, resourceId?, scope?): boolean
  cannot(action, resource, resourceId?, scope?): boolean
  update(newPermissions: PermissionMap): void
}
{
  permissions: Ref<PermissionMap>  // reactive
  can(action, resource, resourceId?, scope?): boolean
  cannot(action, resource, resourceId?, scope?): boolean
  update(newPermissions: PermissionMap): void
}

Use the Can and Cannot components

src/components/Toolbar.vue
<template>
  <nav>
    <Can action="create" resource="post">
      <button>New Post</button>
      <template #fallback>
        <span>No permission</span>
      </template>
    </Can>
 
    <Cannot action="manage" resource="dashboard">
      <p>Admin access required.</p>
    </Cannot>
  </nav>
</template>
 
<script setup>
import { Can, Cannot } from '@/lib/access-client'
</script>
src/components/Toolbar.vue
<template>
  <nav>
    <Can action="create" resource="post">
      <button>New Post</button>
      <template #fallback>
        <span>No permission</span>
      </template>
    </Can>
 
    <Cannot action="manage" resource="dashboard">
      <p>Admin access required.</p>
    </Cannot>
  </nav>
</template>
 
<script setup>
import { Can, Cannot } from '@/lib/access-client'
</script>

Can component:

  • Default slot: rendered when permission is granted
  • #fallback slot: rendered when permission is denied
  • Props: action, resource, resourceId?, scope?

Low-level reactive state

For advanced scenarios, use createAccessState() directly:

import { createAccessState } from '@/lib/access-client'
 
const state = createAccessState(initialPermissions)
 
// state.permissions is a Vue Ref
// state.can() and state.cannot() are reactive
// state.update(newPerms) replaces permissions
import { createAccessState } from '@/lib/access-client'
 
const state = createAccessState(initialPermissions)
 
// state.permissions is a Vue Ref
// state.can() and state.cannot() are reactive
// state.update(newPerms) replaces permissions

This gives you reactive access state without the provide/inject system.

Vanilla JavaScript

For any framework or no framework at all:

Create a client

src/access-client.ts
import { AccessClient } from '@gentleduck/iam/client/vanilla'
 
// From a server endpoint
const access = await AccessClient.fromServer('/api/permissions')
 
// Or from an existing map
const access = new AccessClient(permissionMap)
 
// Or empty (update later)
const access = new AccessClient()
src/access-client.ts
import { AccessClient } from '@gentleduck/iam/client/vanilla'
 
// From a server endpoint
const access = await AccessClient.fromServer('/api/permissions')
 
// Or from an existing map
const access = new AccessClient(permissionMap)
 
// Or empty (update later)
const access = new AccessClient()

Constructor: new AccessClient(permissions?: PermissionMap)

Static factory: AccessClient.fromServer(url: string, init?: RequestInit): Promise<AccessClient>

  • Fetches the URL, parses the JSON response as a PermissionMap
  • init is passed to fetch() for headers, credentials, etc.

Check permissions

// Basic checks
access.can('create', 'post')           // true/false
access.cannot('delete', 'post')        // true/false
 
// With resource ID
access.can('update', 'post', 'post-1')
 
// With scope
access.can('manage', 'user', undefined, 'acme')
 
// Read-only access to the map
console.log(access.permissions)  // Readonly<PermissionMap>
// Basic checks
access.can('create', 'post')           // true/false
access.cannot('delete', 'post')        // true/false
 
// With resource ID
access.can('update', 'post', 'post-1')
 
// With scope
access.can('manage', 'user', undefined, 'acme')
 
// Read-only access to the map
console.log(access.permissions)  // Readonly<PermissionMap>

Query permissions

// Get all actions allowed on a resource
const postActions = access.allowedActions('post')
// ['create', 'read', 'update']
 
// Check if user has ANY permission on a resource
if (access.hasAnyOn('dashboard')) {
  showDashboardLink()
}
// Get all actions allowed on a resource
const postActions = access.allowedActions('post')
// ['create', 'read', 'update']
 
// Check if user has ANY permission on a resource
if (access.hasAnyOn('dashboard')) {
  showDashboardLink()
}

allowedActions() scans the permission map for all keys containing the resource type and returns the unique actions that are true. hasAnyOn() returns true if any permission involving the resource is granted.

Update and subscribe

// Replace all permissions
access.update(newPermissions)
 
// Merge new permissions into existing ones
access.merge({ 'manage:dashboard': true })
 
// Subscribe to changes
const unsubscribe = access.subscribe((permissions) => {
  console.log('Permissions changed:', permissions)
  rerenderUI()
})
 
// Clean up
unsubscribe()
// Replace all permissions
access.update(newPermissions)
 
// Merge new permissions into existing ones
access.merge({ 'manage:dashboard': true })
 
// Subscribe to changes
const unsubscribe = access.subscribe((permissions) => {
  console.log('Permissions changed:', permissions)
  rerenderUI()
})
 
// Clean up
unsubscribe()

The subscribe() method lets you react to permission changes. Both update() and merge() notify all subscribers. This is useful for:

  • Real-time updates via WebSockets
  • Re-rendering after a role change
  • Syncing permissions across tabs

Complete AccessClient API

Method / PropertyTypeDescription
new AccessClient(perms?)ConstructorCreate with optional initial permissions
AccessClient.fromServer(url, init?)StaticFetch permissions from a server
.can(action, resource, id?, scope?)booleanCheck if permission is granted
.cannot(action, resource, id?, scope?)booleanCheck if permission is denied
.permissionsReadonly<PermissionMap>Read-only access to the map
.update(perms)voidReplace all permissions
.merge(perms)voidMerge into existing permissions
.subscribe(listener)() => voidListen for changes, returns unsubscribe
.allowedActions(resource)string[]All allowed actions on a resource
.hasAnyOn(resource)booleanAny permission granted on a resource

Scoped Permissions in the Client

When your app is multi-tenant, include the scope in permission checks:

// React
const { can } = useAccess()
can('manage', 'user', undefined, 'acme')
 
// Vue
const { can } = useAccess()
can('manage', 'user', undefined, 'acme')
 
// Vanilla
access.can('manage', 'user', undefined, 'acme')
// React
const { can } = useAccess()
can('manage', 'user', undefined, 'acme')
 
// Vue
const { can } = useAccess()
can('manage', 'user', undefined, 'acme')
 
// Vanilla
access.can('manage', 'user', undefined, 'acme')

The client looks up the key 'acme:manage:user' in the permission map.

The server must include scoped permissions in the generated map:

const permissions = await getPermissions(engine, userId, [
  { action: 'manage', resource: 'user', scope: 'acme' },
  { action: 'manage', resource: 'user', scope: 'globex' },
])
// { "acme:manage:user": true, "globex:manage:user": false }
const permissions = await getPermissions(engine, userId, [
  { action: 'manage', resource: 'user', scope: 'acme' },
  { action: 'manage', resource: 'user', scope: 'globex' },
])
// { "acme:manage:user": true, "globex:manage:user": false }

Type Safety

All client APIs accept generic type parameters for compile-time checking:

// React
const { AccessProvider, useAccess, Can } = createAccessControl<
  'read' | 'create' | 'update' | 'delete' | 'manage',  // TAction
  'post' | 'comment' | 'user' | 'dashboard',              // TResource
  'acme' | 'globex'                                        // TScope
>(React)
 
// Now this is a compile error:
// can('publish', 'post')  // 'publish' is not a valid action
 
// Vanilla
const access = new AccessClient<
  'read' | 'create' | 'update' | 'delete',
  'post' | 'comment',
  'acme' | 'globex'
>(permissionMap)
// React
const { AccessProvider, useAccess, Can } = createAccessControl<
  'read' | 'create' | 'update' | 'delete' | 'manage',  // TAction
  'post' | 'comment' | 'user' | 'dashboard',              // TResource
  'acme' | 'globex'                                        // TScope
>(React)
 
// Now this is a compile error:
// can('publish', 'post')  // 'publish' is not a valid action
 
// Vanilla
const access = new AccessClient<
  'read' | 'create' | 'update' | 'delete',
  'post' | 'comment',
  'acme' | 'globex'
>(permissionMap)

Security Note

The permission map is a UX optimization, not a security boundary. It controls what the UI shows, but the server still enforces access on every request (Chapter 6). Even if a user tampers with the client-side map (via browser devtools or intercepting the response), the server will deny unauthorized actions.

Defense in depth: server enforces, client adapts.


Chapter 7 FAQ

Is it safe to send permissions to the client?

Yes, but treat it as a UX optimization, not a security boundary. The permission map controls what the UI shows, but the server still enforces access on every request. Even if a user tampers with the client-side map, the server will deny unauthorized actions. Only include permissions the user needs to know about -- you do not need to send every possible permission.

How do I refresh permissions after a role change?

Re-fetch the permission map from the server. In React, pass a new permissions prop to AccessProvider (or re-render the server component). With the vanilla client, call access.update(newMap) and subscribers will be notified. In Vue, call the update() function from useAccess() or provideAccess().

Can I use this with server-side rendering?

Yes. Generate the permission map on the server (in a Server Component, getServerSideProps, or your SSR data loader) and pass it to the client as props. The AccessProvider hydrates with the server-generated map, so there is no flash of unauthorized content and no extra client-side fetch needed.

Why do the factories take the framework as a parameter?

To avoid a hard dependency on React or Vue. duck-iam is a single package that supports multiple frameworks. By accepting the framework instance as a parameter, the client code is tree-shakeable -- if you only use the vanilla client, React and Vue are never bundled. This also means duck-iam works with any version of React or Vue.

Is the permission check fast enough for rendering?

Yes. Client-side can() is a synchronous key lookup in a plain object -- O(1). There are no network calls, no async operations, no engine evaluations. You can call it hundreds of times per render without performance concern. The can() function in React's useAccess() is also memoized.

When should I use usePermissions vs AccessProvider?

Use AccessProvider with server-rendered permissions (SSR/RSC) for the best user experience -- no loading state, no flash. Use usePermissions in pure SPAs where permissions must be fetched client-side. You can combine both: use the provider for initial permissions and usePermissions for dynamic refreshes.

How do I make the vanilla client reactive?

Use the subscribe() method. Every time update() or merge() is called, all subscribers are notified with the new permission map. Connect this to your UI framework's re-render mechanism: access.subscribe(() => forceUpdate()) or use it with a state management library.


Next: Chapter 8: Production Readiness