owner/admin/member/viewer).useConvexAuth().user is great for identity and optional UI claims, but your authoritative role/permission context should come from a Convex query (for example usePermissions() / api.auth.getPermissionContext), not a JWT claim alone.Four roles from most to least privileged:
| Role | Description |
|---|---|
owner | Full control, billing, can invite admins |
admin | Manage members, content moderation |
member | Create/edit own content |
viewer | Read-only access |
const ROLES = ['owner', 'admin', 'member', 'viewer'] as const
type Role = 'owner' | 'admin' | 'member' | 'viewer'
Create a shared permission config used by both frontend and backend.
export const ROLES = ['owner', 'admin', 'member', 'viewer'] as const
export type Role = (typeof ROLES)[number]
export const permissions = {
// Global permissions (org-level, no resource)
global: {
'org.settings': { roles: ['owner'] },
'org.billing': { roles: ['owner'] },
'org.invite': { roles: ['owner', 'admin'] },
'org.members': { roles: ['owner', 'admin'] },
},
// Resource permissions
post: {
create: { roles: ['owner', 'admin', 'member'] },
read: { roles: ['owner', 'admin', 'member', 'viewer'] },
// Ownership rules: different for own vs others' posts
update: { own: ['member'], any: ['owner', 'admin'] },
delete: { own: ['member'], any: ['owner', 'admin'] },
publish: { roles: ['owner', 'admin'] },
},
comment: {
create: { roles: ['owner', 'admin', 'member', 'viewer'] },
update: { own: ['viewer'], any: ['owner', 'admin'] },
delete: { own: ['viewer'], any: ['owner', 'admin', 'member'] },
},
} as const
Simple: Any user with the role can perform the action.
create: {
roles: ['owner', 'admin', 'member']
}
Ownership: Different rules for own resources vs others'.
update: {
own: ['member'], // Members can update their own
any: ['owner', 'admin'], // Owners/admins can update any
}
Access role and permission checking in components.
const {
can, // (permission, resource?) => ComputedRef<boolean>
role, // ComputedRef<Role | null>
orgId, // ComputedRef<Id | null>
isAuthenticated, // ComputedRef<boolean>
pending, // ComputedRef<boolean>
} = usePermissions()
can() returns a ComputedRef<boolean>. Vue automatically unwraps it in templates, so you can use v-if="can('post.create')" directly. The UI will reactively update if the user's permissions change.<script setup lang="ts">
const { can, role } = usePermissions()
const { data: posts } = await useConvexQuery(api.posts.list, {})
</script>
<template>
<!-- Global permission (no resource) -->
<button v-if="can('org.settings')">Organization Settings</button>
<button v-if="can('org.invite')">Invite Members</button>
<!-- Simple resource permission -->
<button v-if="can('post.create')">New Post</button>
<!-- Ownership permission (pass the resource) -->
<div v-for="post in posts" :key="post._id">
<h3>{{ post.title }}</h3>
<!-- Member can edit own, owner/admin can edit any -->
<button v-if="can('post.update', post)">Edit</button>
<!-- Same for delete -->
<button v-if="can('post.delete', post)">Delete</button>
<!-- Only owner/admin can publish -->
<button v-if="can('post.publish')">Publish</button>
</div>
</template>
Page-level protection with redirect.
<script setup lang="ts">
// Redirect if user doesn't have permission
usePermissionGuard('org.settings', '/dashboard')
</script>
useConvexCallserverConvexQuery
::import { api } from '~~/convex/_generated/api'
export default defineNuxtRouteMiddleware(async () => {
const context = import.meta.server
? await serverConvexQuery(api.auth.getPermissionContext, {})
: await useConvexCall({ timeoutMs: 5000 }).query(api.auth.getPermissionContext, {})
if (!context || context.role !== 'admin') {
return navigateTo('/')
}
})
useConvexQuery/useConvexPaginatedQuery should stay in component setup scope. For middleware use one-shot calls.<script setup lang="ts">
const { can, orgId } = usePermissions()
// Only fetch if user has permission
const { data: members, status } = await useConvexQuery(
api.organizations.getMembers,
computed(() => (orgId.value && can('org.members') ? {} : undefined)),
)
</script>
<template>
<section v-if="can('org.members')">
<h2>Team Members</h2>
<div v-if="status === 'pending'">Loading...</div>
<div v-else-if="status === 'success'">
<MemberCard v-for="m in members" :key="m._id" :member="m" />
</div>
</section>
</template>
import { mutation } from './_generated/server'
import { v } from 'convex/values'
import { authorize } from './lib/permissions'
// Global permission check
export const inviteMember = mutation({
args: { email: v.string(), role: v.string() },
handler: async (ctx, args) => {
const user = await authorize(ctx, 'org.invite')
// If we get here, user is authenticated AND has permission
// ...
},
})
// Resource permission check
export const updatePost = mutation({
args: { id: v.id('posts'), title: v.string() },
handler: async (ctx, args) => {
const post = await ctx.db.get(args.id)
if (!post) throw new Error('Not found')
// Checks ownership rules: member can update own, admin can update any
const user = await authorize(ctx, 'post.update', post)
await ctx.db.patch(args.id, { title: args.title })
},
})
export const deletePost = mutation({
args: { id: v.id('posts') },
handler: async (ctx, args) => {
const post = await ctx.db.get(args.id)
if (!post) throw new Error('Not found')
await authorize(ctx, 'post.delete', post)
await ctx.db.delete(args.id)
},
})
"Unauthorized")"Forbidden")"Forbidden: permission.name")import { getUser, requireUser } from './lib/permissions'
// Returns null if not authenticated
export const listPosts = query({
handler: async (ctx) => {
const user = await getUser(ctx)
if (!user) return []
return ctx.db
.query('posts')
.withIndex('by_organization', (q) => q.eq('organizationId', user.organizationId))
.collect()
},
})
// Throws if not authenticated
export const getProfile = query({
handler: async (ctx) => {
const user = await requireUser(ctx)
return user
},
})
import { getUser, requireSameOrg } from './lib/permissions'
export const getPost = query({
args: { id: v.id('posts') },
handler: async (ctx, args) => {
const user = await getUser(ctx)
const post = await ctx.db.get(args.id)
// Returns false if user can't access (different org)
if (!requireSameOrg(user, post)) return null
return post
},
})
import { getUser } from './lib/permissions'
import { checkPermission } from '../permissions.config'
export const getMembers = query({
handler: async (ctx) => {
const user = await getUser(ctx)
if (!user) return []
// Check without throwing
const canView = checkPermission({ role: user.role, userId: user.authId }, 'org.members')
if (!canView) return []
return ctx.db
.query('users')
.withIndex('by_organization', (q) => q.eq('organizationId', user.organizationId))
.collect()
},
})
permissions: true in module config.import { createPermissions } from '#imports'
export const { usePermissions, usePermissionGuard } = createPermissions({
query: api.auth.getPermissionContext,
checkPermission,
})
| Option | Type | Description |
|---|---|---|
query | FunctionReference<'query'> | Convex query returning permission context |
checkPermission | (ctx, permission, resource?) => boolean | Permission checking function |
const { can, role, orgId, user, isAuthenticated, pending } = usePermissions()
| Property | Type | Description |
|---|---|---|
can | (permission, resource?) => ComputedRef<boolean> | Check if user has permission |
role | ComputedRef<string | null> | Current user's role |
orgId | ComputedRef<string | null> | Current user's organization ID |
user | ComputedRef<PermissionContext | null> | Full permission context |
isAuthenticated | ComputedRef<boolean> | Whether user has valid context |
pending | ComputedRef<boolean> | Whether context is loading |
usePermissionGuard({
permission: 'org.settings',
redirectTo: '/dashboard',
loginPath: '/auth/signin',
resource: post, // Optional, for ownership checks
})
| Option | Type | Default | Description |
|---|---|---|---|
permission | string | Required | Permission to check |
redirectTo | string | '/' | Redirect if denied |
loginPath | string | '/auth/signin' | Redirect if not authenticated |
resource | { ownerId?: string } | - | Resource for ownership check |
isOwner, isAdmin, or app-specific context. See the Extending with Custom Helpers section in the setup guide.// WRONG: No backend protection
// Backend mutation:
export const deletePost = mutation({
args: { id: v.id('posts') },
handler: async (ctx, args) => {
await ctx.db.delete(args.id) // Anyone can delete!
},
})
// RIGHT: Always authorize on backend
export const deletePost = mutation({
args: { id: v.id('posts') },
handler: async (ctx, args) => {
const post = await ctx.db.get(args.id)
await authorize(ctx, 'post.delete', post)
await ctx.db.delete(args.id)
},
})
// WRONG: Hardcoded role checks
if (user.role === 'owner' || user.role === 'admin') {
// can invite
}
// RIGHT: Use permission system
if (checkPermission(ctx, 'org.invite')) {
// can invite
}
<!-- WRONG: No resource passed -->
<button v-if="can('post.update')">Edit</button>
<!-- Returns false for members! -->
<!-- RIGHT: Pass the resource -->
<button v-if="can('post.update', post)">Edit</button>
<!-- Checks if member owns this post -->
// WRONG: Returns posts from any org
export const list = query({
handler: async (ctx) => {
return ctx.db.query('posts').collect()
},
})
// RIGHT: Filter by user's org
export const list = query({
handler: async (ctx) => {
const user = await getUser(ctx)
if (!user) return []
return ctx.db
.query('posts')
.withIndex('by_organization', (q) => q.eq('organizationId', user.organizationId))
.collect()
},
})