Skip to main content

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 the UI needs 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 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 buildPermissionKey():

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 reads the map. Checks are 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,
  usePermissions,
  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,
  usePermissions,
  Can,
  Cannot,
  AccessContext,
} = createAccessControl(React)

createAccessControl takes the React instance to avoid a hard dependency on React. If you only use the vanilla client, React is never bundled.

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>
  )
}

Permissions are available to all child components via React context. can() is memoized internally.

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>
  )
}
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 key lookups.

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>
  )
}
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.

Fetch permissions dynamically (SPA pattern)

src/components/app.tsx
import { usePermissions } from '@/lib/access-client'
 
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 '@/lib/access-client'
 
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>
  )
}
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
}

Fetches on mount and re-fetches when deps change. While loading or on error, can() 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
}

Works in any environment: server, client, tests, or Node.js scripts.

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 })

Vue utilities are passed as a parameter to avoid a hard dependency, keeping the package tree-shakeable.

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')

Exposes $can and $cannot on all component instances via 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>

Returns reactive access state and provides it to all descendants. update(newPerms) replaces 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>
{
  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>
  • Default slot: rendered when permission is granted
  • #fallback slot: rendered when permission is denied
  • Props: action, resource, resourceId?, scope?

Low-level reactive state

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

Use createAccessState() directly when you need reactive access state without the provide/inject system.

Vanilla JavaScript

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()

AccessClient.fromServer(url, init?) fetches the URL and 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 map and returns unique actions that are true. hasAnyOn() returns true if any permission on 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()

Both update() and merge() notify all subscribers. Useful for real-time updates via WebSockets, re-rendering after role changes, or 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

For multi-tenant apps, 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')

This looks up '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, the server will deny unauthorized actions.

Server enforces. Client adapts.


Chapter 7 FAQ


Next: Chapter 8: Production Readiness