Reference

Permissions

How roles and permissions work, and how to add your own.

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. authUser looks 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, and can("tasks.delete") from useUserRole() 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:

  1. Add keys to shared/permissions.ts:
    "tasks.view":   ["owner", "admin", "member"],
    "tasks.create": ["owner", "admin", "member"],
    "tasks.update": ["owner", "admin"],
    "tasks.delete": ["owner"],
    
  2. 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");
    
  3. Gate UI elements:
    <CanAccess permission="tasks.delete">
      <UButton label="Delete" @click="confirmDelete(task)" />
    </CanAccess>
    
  4. (Optional) Register the table in tablePermissions so 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:

  1. Add "viewer" to the TeamRole union.
  2. Add "viewer" to the permission entries it should have access to.
  3. 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

ScenarioUse
Whole page requires a permissionrequirePermission("...") 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 scriptcan("...")
Gate a useFetch at setupimmediate: 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).