This scaffold gives you a canonical org-based model:
owner, admin, member, viewercheckPermission() logic for frontend + backendauthorize() guard for mutations/actionsusePermissions() composable via createPermissionsexport default defineNuxtConfig({
modules: ['better-convex-nuxt'],
convex: {
permissions: true,
},
})
export const ROLES = ['owner', 'admin', 'member', 'viewer'] as const
export type Role = (typeof ROLES)[number]
export const permissions = {
global: {
'org.settings': { roles: ['owner'] },
'org.invite': { roles: ['owner', 'admin'] },
'org.members': { roles: ['owner', 'admin'] },
},
post: {
create: { roles: ['owner', 'admin', 'member'] },
read: { roles: ['owner', 'admin', 'member', 'viewer'] },
update: { own: ['member'], any: ['owner', 'admin'] },
delete: { own: ['member'], any: ['owner', 'admin'] },
publish: { roles: ['owner', 'admin'] },
},
} as const
type GlobalPermission = keyof (typeof permissions)['global']
type PostPermission = `post.${keyof (typeof permissions)['post']}`
export type Permission = GlobalPermission | PostPermission
export interface PermissionContext {
role: Role
userId: string
organizationId: string | null
}
export interface Resource {
ownerId?: string
organizationId?: string
}
type PermissionRule = { roles: readonly Role[] } | { own: readonly Role[]; any: readonly Role[] }
export function checkPermission(
ctx: PermissionContext | null,
permission: Permission,
resource?: Resource,
): boolean {
if (!ctx) return false
if (permission in permissions.global) {
const rule = permissions.global[permission as GlobalPermission]
return rule.roles.includes(ctx.role)
}
const [resourceType, action] = permission.split('.') as [string, string]
const resourcePerms = permissions[resourceType as keyof typeof permissions]
if (!resourcePerms || resourceType === 'global') return false
const rule = (resourcePerms as Record<string, PermissionRule>)[action]
if (!rule) return false
if ('roles' in rule) {
return rule.roles.includes(ctx.role)
}
if (rule.any.includes(ctx.role)) return true
if (!resource?.ownerId) return false
return rule.own.includes(ctx.role) && resource.ownerId === ctx.userId
}
import { ConvexError } from 'convex/values'
import type { MutationCtx, QueryCtx, ActionCtx } from '../_generated/server'
import {
checkPermission,
type Permission,
type Resource,
type PermissionContext,
} from '../permissions.config'
type Ctx = MutationCtx | QueryCtx | ActionCtx
async function getPermissionContext(ctx: Ctx): Promise<PermissionContext | null> {
const identity = await ctx.auth.getUserIdentity()
if (!identity) return null
const user = await ctx.db
.query('users')
.withIndex('by_auth_id', (q) => q.eq('authId', identity.subject))
.first()
if (!user) return null
return {
role: user.role,
userId: identity.subject,
organizationId: user.organizationId ?? null,
}
}
export async function authorize(
ctx: Ctx,
permission: Permission,
resource?: Resource,
): Promise<PermissionContext> {
const context = await getPermissionContext(ctx)
if (!context) {
throw new ConvexError('Unauthorized')
}
if (
resource?.organizationId &&
context.organizationId &&
resource.organizationId !== context.organizationId
) {
throw new ConvexError('Forbidden')
}
if (!checkPermission(context, permission, resource)) {
throw new ConvexError('Forbidden')
}
return context
}
import { query } from './_generated/server'
export const getPermissionContext = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) return null
const user = await ctx.db
.query('users')
.withIndex('by_auth_id', (q) => q.eq('authId', identity.subject))
.first()
if (!user) return null
return {
userId: identity.subject,
role: user.role,
organizationId: user.organizationId ?? null,
isAuthenticated: true,
}
},
})
import { api } from '~~/convex/_generated/api'
import { checkPermission, type Permission, type Resource } from '~~/convex/permissions.config'
export const usePermissions = createPermissions<
{ role: 'owner' | 'admin' | 'member' | 'viewer'; userId: string; organizationId: string | null },
Permission,
Resource
>({
query: api.auth.getPermissionContext,
checkPermission,
})
Use in components:
<script setup lang="ts">
const { can, role } = usePermissions()
</script>
<template>
<button v-if="can('post.create')">New Post</button>
<button v-if="can('org.invite')">Invite Member</button>
</template>
post (for example comment, invoice, project)authorize() for all org-scoped resourcesForbidden errors with domain-specific errors when needed