Permissions
Roles and permissions are defined in a single file: shared/permissions.ts. Both the server (authUser) and client (useUserRole, <CanAccess>) read from this map. Adding a new role or changing what a role can do means editing this one file.
The permissions file
// shared/permissions.ts
export type TeamRole = "owner" | "admin" | "member";
export const assignableRoles = ["admin", "member"] as const;
export const permissions = {
// UI-only visibility gates — no corresponding server action
"visible.member": ["owner", "admin", "member"],
"visible.admin": ["owner", "admin"],
"visible.owner": ["owner"],
// Action permissions — enforced server-side via authUser()
"team.view": ["owner", "admin", "member"],
"team.update": ["owner"],
"team.delete": ["owner"],
"members.view": ["owner", "admin", "member"],
"members.invite": ["owner", "admin"],
"members.remove": ["owner", "admin"],
"members.role.change": ["owner", "admin"],
"invitations.view": ["owner", "admin"],
"invitations.revoke": ["owner", "admin"],
"settings.team": ["owner"],
} as const satisfies Record<string, readonly TeamRole[]>;
export type Permission = keyof typeof permissions;TeamRole— the union of all roles. Add new roles here.assignableRoles— roles that can be given via invitation or role change. This drives the Zod schemas in API routes and the role selector in the invite modal. "owner" is excluded because ownership is set at team creation, not assigned.permissions— the map has two kinds of entries:visible.*— UI-only visibility gates. Use these with<CanAccess>when you want to show or hide a template element based on role without tying it to a specific action. They have no server-side enforcement.- Action keys (everything else) — enforced server-side via
authUser(). The dot-separated names are a convention (resource.action), not a framework feature. Each key is manually referenced in the API route that performs that action.
How permissions are enforced
Permission keys are just strings in a lookup table. Nothing automatically maps them to database tables or operations. A permission only does something when code explicitly references it:
- Server-side: A developer writes
await authUser(event, "tasks.delete")at the top of an API route.authUserlooks up the key in the permissions map, checks if the user's role is in the allowed list, and throws 403 if not. - Client-side:
<CanAccess permission="tasks.delete">hides UI elements the user cannot act on. This is a UX convenience — even if bypassed, the server rejects the request.
The resource.action naming convention (e.g. members.remove, team.delete) is just for readability. There is no framework parsing these strings.
Adding permissions for a new resource
When you add a new table with CRUD endpoints, add one permission key per endpoint and reference it in the corresponding route:
- Add keys to
shared/permissions.ts:ts"tasks.view": ["owner", "admin", "member"], "tasks.create": ["owner", "admin", "member"], "tasks.update": ["owner", "admin"], "tasks.delete": ["owner"], - Gate each API route:ts
// server/api/teams/[teamId]/tasks/index.get.ts const { client } = await authUser(event, "tasks.view"); // server/api/teams/[teamId]/tasks/index.post.ts const { client } = await authUser(event, "tasks.create"); // server/api/teams/[teamId]/tasks/[taskId].patch.ts const { client } = await authUser(event, "tasks.update"); // server/api/teams/[teamId]/tasks/[taskId].delete.ts const { client } = await authUser(event, "tasks.delete"); - Gate UI elements:vue
<CanAccess permission="tasks.delete"> <UButton label="Delete" @click="confirmDelete(task)" /> </CanAccess>
Adding a new role
To add a "viewer" role that can only view teams and members:
- Add
"viewer"to theTeamRoleunion. - Add
"viewer"to the permission entries it should have access to. - If the role can be assigned via invitation, add it to
assignableRoles.
export type TeamRole = "owner" | "admin" | "member" | "viewer";
export const assignableRoles = ["admin", "member", "viewer"] as const;
export const permissions = {
"team.view": ["owner", "admin", "member", "viewer"],
"members.view": ["owner", "admin", "member", "viewer"],
// ... everything else stays the same
} as const satisfies Record<string, readonly TeamRole[]>;No other files need to change. The server will enforce the new role on API requests, the UI will show/hide elements accordingly, and the invite modal will offer the new role as an option.
Server-side: authUser(event, permission)
Every protected API route calls authUser with a permission key. The function verifies the JWT, confirms team membership, and checks that the user's role is in the permission's allowed list. If not, it throws a 403.
// server/api/teams/[teamId]/index.patch.ts
export default defineEventHandler(async (event) => {
const { client, profile } = await authUser(event, "team.update");
// ... only owners reach this point
});For routes that do not need team context (e.g., profile updates, team creation), use authUserOnly(event) which only verifies the JWT.
Client-side: <CanAccess> component
<CanAccess> is a renderless component that shows its default slot when the user has the given permission, and an optional fallback slot when they do not.
Hide an element entirely:
<CanAccess permission="members.invite">
<UButton label="Invite people" @click="inviteOpen = true" />
</CanAccess>Show a read-only fallback instead:
<CanAccess permission="members.role.change">
<USelect :model-value="member.role" :items="[...assignableRoles]" @update:model-value="changeRole(member, $event)" />
<template #fallback>
<UBadge :label="member.role" color="neutral" variant="subtle" />
</template>
</CanAccess>With v-if on the component (for owner-specific rendering):
<CanAccess v-if="member.role !== 'owner'" permission="members.remove">
<UButton label="Remove" @click="confirmRemove(member)" />
</CanAccess>Client-side: can() in scripts
For logic that runs in <script setup> — watchers, computed properties, conditional data fetching — use the can() function from useUserRole().
const { can, role, isRoleKnown } = useUserRole();
// Gate a deferred fetch
watch(teamId, (tid) => {
if (tid && can("invitations.view")) refreshInvitations();
}, { immediate: true });
// Redirect non-owners away from team settings
watch([isRoleKnown], ([known]) => {
if (known && !can("settings.team")) navigateTo("/settings", { replace: true });
}, { immediate: import.meta.client });
// Conditionally include a nav item
if (can("settings.team")) {
items.push({ label: "Team", to: "/settings/team" });
}When to use <CanAccess> vs can()
| Scenario | Use |
|---|---|
| Show/hide a template element based on permission | <CanAccess permission="..."> |
| Show a different element when permission is denied | <CanAccess> with #fallback slot |
| Build menu items or nav links in script | can("...") |
| Guard a watcher or conditional fetch | can("...") |
| Redirect from a page the user cannot access | can("...") in a watcher |