Skip to main content

Vue client

createIamVueAccess - reactive access state, a plugin, provide/inject, the useAccess composable, and Can/Cannot slot components

@gentleduck/iam/client/vue builds a Vue 3 access-control surface from a permission map. You inject Vue's own reactivity helpers into the factory, so duck-iam never imports Vue - it is not even a peer dependency.

Install


npm i @gentleduck/iam
import {
  createIamVueAccess,
  IAM_ACCESS_INJECTION_KEY,
  iamAllowedActions,
  iamBuildPermissionKey,
  iamHasAnyOn,
} from '@gentleduck/iam/client/vue'

Those are the module's exports; the last three are re-exports of the shared key helpers. useAccess, usePermissions, createAccessPlugin, provideAccess, createAccessState, Can, and Cannot all come out of the factory - none of them is importable directly.

Setup

Call the factory once and export the result, so the whole app shares one set of bindings.

// lib/access.ts
import { computed, defineComponent, h, inject, provide, ref } from 'vue'
import { createIamVueAccess } from '@gentleduck/iam/client/vue'

type Action = 'create' | 'read' | 'update' | 'delete' | 'manage'
type Resource = 'post' | 'team' | 'analytics'
type Scope = 'org-1'

export const {
  createAccessState, provideAccess, useAccess, usePermissions,
  createAccessPlugin, Can, Cannot, IAM_ACCESS_INJECTION_KEY,
} = createIamVueAccess<Action, Resource, Scope>({ ref, computed, inject, provide, defineComponent, h })

The generics are, in order, TAction, TResource, TScope, all constrained to string and defaulting to string.

How a check resolves

Loading diagram...

Every path funnels into one state object. createAccessPlugin and provideAccess differ only in when and where the state is registered - the plugin registers it on the app instance at app.use() time, provideAccess registers it from a component's setup(). The ERR edge has no environment gate: Vue throws for a missing provider in every build. React was aligned to it, but only under NODE_ENV=development.

createIamVueAccess

function createIamVueAccess<
  TAction extends string = string,
  TResource extends string = string,
  TScope extends string = string,
>(vue: VueLike): {
  createAccessState: (initialPermissions: IamClient.PartialPermissionMap<TAction, TResource, TScope>) => AccessState
  provideAccess: (permissions: IamClient.PartialPermissionMap<TAction, TResource, TScope>) => AccessState
  useAccess: () => AccessState
  usePermissions: (
    fetchFn: () => Promise<IamClient.PartialPermissionMap<TAction, TResource, TScope>>,
  ) => AsyncAccessState
  createAccessPlugin: (permissions: IamClient.PartialPermissionMap<TAction, TResource, TScope>) => {
    install(app: VueApp): void
  }
  Can: unknown
  Cannot: unknown
  IAM_ACCESS_INJECTION_KEY: symbol
}

The vue parameter is structurally typed as VueLike and must supply six members:

MemberRequired by the typeUsed at runtime
refyesyes - holds the permission map
computedyesno
injectyesyes - useAccess
provideyesyes - provideAccess
defineComponentyesyes - Can and Cannot
hyesno

computed and h are part of the injected surface but the current implementation does not call them. Pass them anyway: the type demands all six, and omitting one is a compile error rather than a runtime saving.

createAccessState

function createAccessState(
  initialPermissions: IamClient.PartialPermissionMap<TAction, TResource, TScope>,
): {
  permissions: Ref<IamClient.PartialPermissionMap<TAction, TResource, TScope>>
  can: (action: TAction, resource: TResource, resourceId?: string, scope?: TScope) => boolean
  cannot: (action: TAction, resource: TResource, resourceId?: string, scope?: TScope) => boolean
  update: (newPerms: IamClient.PartialPermissionMap<TAction, TResource, TScope>) => void
  allowedActions: (resource: TResource) => string[]
  hasAnyOn: (resource: TResource) => boolean
}

The lowest-level entry point. It wraps the map in a ref and returns six members; it registers nothing.

MemberTypeDescription
permissionsRef<PartialPermissionMap>The reactive map. Read permissions.value
can(action, resource, resourceId?, scope?) => booleanReads permissions.value on every call, so it tracks the ref. true only for a key whose value is the boolean true
cannot(action, resource, resourceId?, scope?) => booleanStrict !can(...)
update(newPerms) => voidAssigns permissions.value, triggering every dependent effect
allowedActions(resource) => string[]Every action the map grants on that resource type
hasAnyOn(resource) => booleanWhether the map grants any action on that resource type

The ref holds the caller's object; Vue does not copy the map. Mutating an object you already handed to createAccessState changes what can() answers and notifies nothing - ref() sees writes made through permissions.value, not writes to the object you still hold. Treat the map as frozen from the moment you pass it.

Because can dereferences the ref at call time rather than closing over the map, calling it inside a template, a computed, or a render function registers a reactive dependency. Calling it once in setup() and storing the boolean does not - that value is a snapshot.

const state = createAccessState(permissionsFromServer)

state.can('delete', 'post') // boolean, right now
state.permissions.value // the reactive map
state.update(nextMap) // replaces it; templates re-render

update replaces the whole map. There is no merge on the Vue client - build the merged object yourself and pass it in:

state.update({ ...state.permissions.value, 'manage:team': true })

createAccessPlugin

function createAccessPlugin(
  permissions: IamClient.PartialPermissionMap<TAction, TResource, TScope>,
): { install(app: VueApp): void }

The whole-app path. install builds a fresh state from the captured map, provides it on the app under IAM_ACCESS_INJECTION_KEY, and registers two global properties.

// main.ts
import { createApp } from 'vue'
import { createAccessPlugin } from '@/lib/access'
import App from './App.vue'

const app = createApp(App)
app.use(createAccessPlugin(permissionsFromServer))
app.mount('#app')
RegisteredValue
app.provide(IAM_ACCESS_INJECTION_KEY, state)The state object useAccess injects
app.config.globalProperties.$canstate.can, callable directly in any template
app.config.globalProperties.$cannotstate.cannot
<button v-if="$can('delete', 'post')">Delete</button>
<p v-if="$cannot('read', 'analytics')">Upgrade to see analytics.</p>

$can and $cannot are bound to the state created at install time, so update() from useAccess inside a component reaches them too - they share one ref.

provideAccess

function provideAccess(
  permissions: IamClient.PartialPermissionMap<TAction, TResource, TScope>,
): AccessState

The component-tree path. It creates the state, calls Vue's provide with IAM_ACCESS_INJECTION_KEY, and returns the state so the providing component can use it too.

<script setup lang="ts">
import { provideAccess } from '@/lib/access'

const props = defineProps<{ permissions: Record<string, boolean> }>()
const { can, update } = provideAccess(props.permissions)
</script>

It calls provide internally, so it must run synchronously inside a component's setup(). Calling it from a module body, an event handler, or after an await in setup has no provider to attach to.

Use it instead of the plugin when the map is per-route or per-subtree, or when one app renders more than one subject's view.

useAccess

function useAccess(): AccessState

Injects the state provided by the plugin, by provideAccess, or by your own provide call under the same key. Returns exactly what createAccessState returns: permissions, can, cannot, update, allowedActions, and hasAnyOn.

<script setup lang="ts">
import { useAccess } from '@/lib/access'

const { can, cannot, permissions, update } = useAccess()

async function refresh() {
  update(await fetch('/api/me/permissions').then((r) => r.json()))
}
</script>

<template>
  <button v-if="can('update', 'post')">Edit</button>
  <button v-if="can('delete', 'post', 'post-42')">Delete this post</button>
  <button v-if="can('manage', 'team', undefined, 'org-1')">Team settings</button>
  <p v-if="cannot('manage', 'team')">Contact an admin to manage teams.</p>
</template>

usePermissions

function usePermissions(
  fetchFn: () => Promise<IamClient.PartialPermissionMap<TAction, TResource, TScope>>,
): {
  permissions: Ref<IamClient.PartialPermissionMap<TAction, TResource, TScope>>
  can: (action: TAction, resource: TResource, resourceId?: string, scope?: TScope) => boolean
  cannot: (action: TAction, resource: TResource, resourceId?: string, scope?: TScope) => boolean
  allowedActions: (resource: TResource) => string[]
  hasAnyOn: (resource: TResource) => boolean
  loading: Ref<boolean>
  error: Ref<Error | null>
  refetch: () => Promise<void>
}

The async path, for a client-rendered app that fetches its own map. It is not wired to the injection system: Can, Cannot and useAccess read the provided state, which has update and no loading. Feed one into the other yourself.

<script setup lang="ts">
import { usePermissions } from '@/lib/access'

const { can, loading, error, refetch } = usePermissions(() =>
  fetch('/api/me/permissions').then((r) => r.json()),
)
</script>

<template>
  <Spinner v-if="loading" />
  <ErrorMessage v-else-if="error" :error="error" @retry="refetch" />
  <NewPostButton v-else-if="can('create', 'post')" />
</template>

permissions, loading and error are Refs - read .value in script, unwrapped in a template.

Behaviour worth knowing:

  • The first load fires synchronously in the composable body, not from a lifecycle hook. Creating the composable starts the request, with nothing to defer or cancel it. React's equivalent runs its first load inside useEffect.
  • Every load starts by resetting. permissions.value goes back to the empty map and error.value is cleared before the fetch is awaited, so during any reload everything is denied, and after a failed reload everything is denied with error populated. Gate the UI on loading, never on a map left over from the previous subject - the sign-out and account-switch cases are exactly where the stale map is another user's grants.
  • Supersession is a monotonic run id. Two loads can be in flight and the earlier one can resolve last; a boolean cannot express that.
  • There is no unmount guard. A late response still writes to the ref of a disposed component. The ref is garbage by then, so the effect is a wasted write rather than a leak, but it is a real difference from React, which carries an unmounted flag set by its effect cleanup.
  • There is no dependency list. React's usePermissions takes one; Vue's does not. Re-run by calling refetch.
  • error is normalised to err instanceof Error ? err : new Error(String(err)).

Can and Cannot

Both are defineComponent results with name: 'Can' and name: 'Cannot', and both call useAccess inside setup - so they inherit the throw above when no provider exists.

<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 see analytics.</p>
    </template>
  </Can>

  <Can action="update" resource="post" resource-id="post-42" scope="org-1">
    <EditButton />
  </Can>

  <Cannot action="create" resource="post">
    <p>You do not have permission to create posts.</p>
  </Cannot>
</template>

Props (identical on both)

PropTypeRequiredDefaultMeaning
actionStringyes-The action to check
resourceStringyes-The resource type to check
resourceIdStringnoundefinedPins the check to one instance
scopeStringnoundefinedScope segment for the key

In templates, write the optional props kebab-cased: resource-id="post-42".

Slots

ComponentConditionRenders
Canallowedthe default slot
Candeniedthe fallback slot, or nothing when no fallback slot is passed
Cannotdeniedthe default slot
Cannotallowednull

Can has no fallback prop - fallback is a named slot. Cannot has no fallback of any kind.

IAM_ACCESS_INJECTION_KEY

const IAM_ACCESS_INJECTION_KEY = Symbol.for('@gentleduck/iam:access')

Registry-global, exported directly from @gentleduck/iam/client/vue and also returned by the factory. provideAccess, createAccessPlugin, and useAccess all use it, so anything provided under this key satisfies useAccess.

Reach for it when you want the state but not the helpers - for example, to provide the same state under an extra key of your own, or to wire duck-iam into a larger plugin:

import { provide } from 'vue'
import { IAM_ACCESS_INJECTION_KEY } from '@gentleduck/iam/client/vue'
import { createAccessState } from '@/lib/access'

const state = createAccessState(permissions)
provide(IAM_ACCESS_INJECTION_KEY, state)

Symbol.for rather than Symbol() is the whole point. The package ships both ESM and CJS builds, and a plain symbol is per-module-instance: a mixed load would have provide and inject holding different keys and report "useAccess() called without provideAccess()" for a correctly wired app. The registry key survives that. The cost is that two Vue surfaces in one process collide on it, and the last provide wins.

SSR and hydration

The safe pattern:

Build the map per request on the server

Call generateIamPermissionMap(engine, subjectId, checks, environment) from @gentleduck/iam/server/generic in the request handler.

Serialise it into the page payload

The map is a flat Record of strings to booleans, so it needs no custom serialiser. Put it in your app's state payload alongside everything else you hydrate.

Provide it inside setup, not at module scope

Either app.use(createAccessPlugin(mapForThisRequest)) on the freshly created app, or provideAccess(mapForThisRequest) in the root component's setup(). Both must see the same map on the server and on the client.

Refresh with update

After a role or scope change, refetch and call update(next) from useAccess. Because can reads permissions.value at call time, every template that called it re-renders. No remount is needed. usePermissions is the other path, but it fires its first fetch synchronously on creation, which is the wrong shape for an SSR render.

Before hydration the state does not exist, so useAccess throws rather than denying. Do not call it above the provider - render the provider as high as the map is available, and gate on a loading flag of your own if the map arrives asynchronously.

When to use what

NeedUse
Whole-app setup, one subject per app instancecreateAccessPlugin plus app.use
Per-route or per-subtree map, SSRprovideAccess in a parent setup()
Reactive state outside the injection systemcreateAccessState
Map fetched in the browser, with loading and errorusePermissions
Check in a template$can / $cannot, or useAccess with v-if
Declarative gate with a fallbackCan with a fallback slot
Provide under your own wiringIAM_ACCESS_INJECTION_KEY plus your own provide

Gotchas

  • Call the factory once. Two calls still share IAM_ACCESS_INJECTION_KEY, so injection works across them, but you end up with two Can components and two composables for no benefit.
  • useAccess throws. Wrap the risky call site or, better, fix the provider. Do not swallow it - a caught throw becomes an invisible always-denied UI.
  • There is no merge. Spread into the current map and call update.
  • A boolean captured in setup() is a snapshot. Call can in the template, in a computed, or in a render function to keep it reactive.
  • computed and h are unused today but still required. Pass all six members of the Vue surface.
  • usePermissions fires its first fetch synchronously. Creating the composable starts the request; there is no lifecycle hook to defer or cancel it, which matters under SSR.
  • Can renders nothing when denied without a fallback slot. That is the intended "hide it" behaviour, not a bug.

See also