Permissions
Roles and permissions are defined in a single file: shared/permissions.ts.
Server (authUser), client (useUserRole, <CanAccess>), route middleware
(requirePermission), and the AI chat tool surface (tablePermissions) all
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"],
"ai.chat": ["owner", "admin", "member"],
"activity.view": ["owner"],
"announcements.view": ["owner", "admin", "member"],
"announcements.create": ["owner", "admin"],
"announcements.update": ["owner", "admin"],
"announcements.delete": ["owner", "admin"],
// Directory data readable by chat users.
"profiles.view": ["owner", "admin", "member"],
"team_members.view": ["owner", "admin", "member"],
// Shared empty role set — used as the create/update/delete entry in
// tablePermissions for any table the LLM may only read.
"readonly.write": [],
} 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. 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. - Route-level:
definePageMeta({ middleware: requirePermission("tasks.view") })redirects before navigation, so users without access never see a flash of page content. - Client-side:
<CanAccess permission="tasks.delete">hides UI elements the user cannot act on, andcan("tasks.delete")fromuseUserRole()gates computed values and conditional logic. 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:"tasks.view": ["owner", "admin", "member"], "tasks.create": ["owner", "admin", "member"], "tasks.update": ["owner", "admin"], "tasks.delete": ["owner"], - Gate each API route:
// 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:
<CanAccess permission="tasks.delete"> <UButton label="Delete" @click="confirmDelete(task)" /> </CanAccess> - (Optional) Register the table in
tablePermissionsso the AI chat can read and write it — see Exposing tables to AI chat below.
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.
It returns an audited Supabase client — every write goes through the
activity log automatically.
// server/api/teams/[teamId]/index.patch.ts
export default defineEventHandler(async (event) => {
const { client, profile, teamMember } = 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.
Route middleware: requirePermission(key, fallback?)
For pages whose entire content requires a permission, use the
requirePermission middleware in app/utils/requirePermission.ts. It runs
before navigation and redirects users without access, so they never see a
flash of page content.
// app/pages/app/settings/team.vue
<script setup lang="ts">
definePageMeta({
middleware: requirePermission("settings.team"),
});
</script>
Fallback defaults to /app. Pass a second argument to override:
definePageMeta({
middleware: requirePermission("activity.view", "/app/settings"),
});
Prefer this over a client-side watcher that redirects after mount — the middleware runs before the page component loads.
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> — computed properties, conditional
nav items, predicate gating for useFetch — use the can() function from
useUserRole().
const { role, can } = useUserRole();
// Gate a useFetch at setup using a synchronous predicate (SSR-safe, because
// useTeam/useUserRole are SSR-hydrated).
const { data: invites, status, refresh } = await useFetch("/api/invitations", {
key: "invitations",
default: () => [],
immediate: can("invitations.view"),
getCachedData,
});
revalidateOnMount(status, refresh);
// Conditionally include a nav item
if (can("settings.team")) {
items.push({ label: "Team", to: "/app/settings/team" });
}
For redirecting away from a page the user can't access, prefer the
requirePermission middleware
over a watcher — it runs before navigation.
When to use <CanAccess> vs can() vs requirePermission
| Scenario | Use |
|---|---|
| Whole page requires a permission | requirePermission("...") as page middleware |
| 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("...") |
Gate a useFetch at setup | immediate: can("...") |
Exposing tables to AI chat
The AI chat can read and write any table registered in the tablePermissions
map in shared/permissions.ts. Tables not listed are invisible to the chat
tools, so adding a new resource to chat is an explicit, per-table decision.
// shared/permissions.ts
export const tablePermissions = {
tasks: {
view: "tasks.view",
create: "tasks.create",
update: "tasks.update",
delete: "tasks.delete",
},
// read-only for chat — writes blocked at the tool layer (readonly.write
// has an empty role set) and at the DB (chat_reader is SELECT only).
activity_log: {
view: "activity.view",
create: "readonly.write",
update: "readonly.write",
delete: "readonly.write",
},
} as const satisfies Record<string, Record<CrudAction, Permission>>;
Each action points at a permission key the chat user's role is checked
against before the tool runs. See AI Chat for the
full table-onboarding checklist (RLS policy for chat_reader, CHECK
constraints, COMMENT ON COLUMN, activity log).