Skip to content

Accounts

Roles and permissions

Every account has one app role, and routes check permissions rather than roles. A permission comes from the account's role or from a plan its organization holds. You work here when you add a staff tool, a paid feature or a new role. The whole model is the access block in starter.config.ts(Licensees only).

Roles inside an organization (owner, admin, member) are a separate system, covered in Organizations.

How it works

access: {
  permissions: ['users:read', 'users:manage', 'blog:author', 'insights:read'],

  defaultRole: 'user',

  firstAccountRole: 'superadmin',

  rolePermissions: {
    user: [],
    staff: ['users:read', 'blog:author'],
    superadmin: ['users:read', 'users:manage', 'blog:author'],
  },

  planPermissions: {
    free: [],
    pro: ['insights:read'],
    promax: ['insights:read'],
    lifetime: ['insights:read'],
  },
},
  1. permissions lists every permission. The Permission and UserRole types come from this block, so a grant or a check naming anything else fails typecheck.
  2. rolePermissions is what each role holds. planPermissions is what each plan sells, with free for an organization that holds no grant.
  3. requirePermission checks the role first. If the role doesn't grant it and a plan sells it, it reads the live grants of the organization you name.
  4. users.role is a text column. Each request that checks the session reads it again, and a value the config doesn't declare reads as defaultRole.

The two sources add up. A superadmin keeps staff powers on the free tier, and staff status never opens a paid feature: to let superadmin read insights, list insights:read under it.

PermissionOpens
users:read/dashboard/customers, the organization directory, account and billing details there
users:manageImpersonating, suspending, editing accounts, support mails, granting plans, refunds
blog:authorLets the account at the blog's author address appear as the byline
insights:readThe example paid feature, sold on every paid plan

Check a permission on the server

requirePermission(event, permission, orgId?) lives in server/utils/permissions.ts(Licensees only) and returns the session:

export default defineEventHandler(async (event) => {
  const session = await requirePermission(event, 'users:read')

  return { viewer: session.user.email }
})

It answers 401 without a verified session. When nothing grants the permission, it answers 402 with data.reason: 'upgrade-required' if some plan sells it and 403 if none does, so the client knows whether an upgrade helps.

A permission a plan sells is held by an organization, so name it. With organizations on, leaving the id out of a check that reaches the plans answers 500:

const scope = await requireOrgScope(event, getRouterParam(event, 'org') ?? '')

await requirePermission(event, 'insights:read', scope.rootOrgId)

With payments off, every permission a plan sells is granted to every signed-in account. Payments has the paid side, including requireEntitlement and the plan gate.

Check a permission in the browser

usePermissions(org?) returns role, can(permission) and pending:

<script setup lang="ts">
const route = useRoute()
const { can, pending } = usePermissions(() => String(route.params.org ?? ''))
</script>

<template>
  <InsightsPanel v-if="can('insights:read')" />
  <UpgradeCard v-else-if="!pending" />
</template>
  • Pass the organization's public id when the permission may come from a plan. With organizations on and none named, plans count for nothing and only the role answers.
  • pending is true while the plans load. Wait for it before showing an upsell, or paying customers see one flash on every load.
  • It subscribes to a query, so call it in component setup. Route middleware calls can(permission, { role }) from #shared/utils/permissions instead, as app/middleware/admin.ts(Licensees only) does to keep accounts without users:read off the staff pages.

What the browser decides is for rendering. The route's requirePermission is the check that counts.

Add a permission

List it in permissions, grant it from a role or a plan, and check it:

access: {
  permissions: [
    'users:read',
    'users:manage',
    'blog:author',
    'insights:read',
    'reports:export',
  ],

  rolePermissions: {
    user: [],
    staff: ['users:read', 'blog:author', 'reports:export'],
    superadmin: [
      'users:read',
      'users:manage',
      'blog:author',
      'reports:export',
    ],
  },
},
await requirePermission(event, 'reports:export')

A permission may be listed with no grant yet. A route gated on it then answers 403 until a role or plan grants it.

Add a role

Add a key to rolePermissions and a label as dashboard.customers.roles.<id> in every file in i18n/locales/. test/unit/role-copy.test.ts(Licensees only) fails on a missing label. Routes check permissions, never role names, so no other file needs the new role.

Staff holding users:manage assign roles from the Edit action on /dashboard/customers. That dialog never offers a role that grants users:manage, and the route refuses one with 403, so a new superadmin is made by hand (Promote an account by hand).

access.defaultRole is written explicitly on signup, so changing it needs no migration. The column's own SQL default of 'user' only covers rows written by hand or by the seed.

Staff tools

Staff holding users:manage get these actions on /dashboard/customers:

ActionRoute
Edit name, email, rolePATCH /api/admin/users/:id
SuspendPOST /api/admin/users/ban
ImpersonatePOST /api/admin/impersonate, POST /api/admin/impersonate/stop
Mail a password resetPOST /api/admin/users/password-reset
Mail a verification linkPOST /api/admin/users/verification-email
Clear sign-in lockoutsPOST /api/admin/users/unlock
Grant or take back a planPOST /api/admin/billing/grant, /revoke
Refund a purchasePOST /api/admin/billing/refund

Each refuses the caller's own account with 400, and an account whose role grants users:manage with 403, so one administrator can't act on another. Granting a plan refuses the same way for an organization you or another administrator belong to. The password reset, verification mail and unlock each allow a staff member 30 uses an hour, and staff never see a mailed link.

An impersonating session is read-only. server/middleware/impersonation.ts(Licensees only) answers every API write, anything but GET, HEAD and OPTIONS, with 403 impersonating. Stopping the impersonation and signing out still work.

The first superadmin

On a fresh deploy, the first account to sign up gets access.firstAccountRole once it proves its email address, as long as no account holds a role that grants users:manage. An OAuth or magic-link signup proves it at once; a password signup proves it by opening the verify link in a browser signed in as the account, or through a password reset. The promotion is logged in the account's activity as admin.firstAccountPromoted.

Register your own account before you share the URL, since the first signup wins. The account is not promoted if it came from a guest checkout, is suspended or scheduled for deletion, or already holds a passkey, TOTP or linked provider. server/auth/first-account.ts(Licensees only) has the whole rule.

Set firstAccountRole: false to turn this off. The build refuses a role that is not declared or that doesn't grant users:manage.

Promote an account by hand

Roles live in users.role. Run one statement, against the local database while pnpm dev runs, or against production with the DATABASE_URL from .env.production:

psql postgres://postgres:postgres@127.0.0.1:5432/postgres -c \
  "UPDATE users SET role = 'superadmin' WHERE email = 'you@example.com'"
psql "$DATABASE_URL" -c \
  "UPDATE users SET role = 'superadmin' WHERE email = 'you@example.com'"

If pnpm dev serves PGlite on another port, use that one: the port NUXT_DEV_PGLITE_PORT pins, or the one its startup log names when 5432 was taken. While the dev server runs, .data/pglite-server.json holds the full URL.

To take the role from a stranger who signed up first, set both rows in one statement so there is never a moment with nobody holding users:manage:

psql "$DATABASE_URL" -c \
  "UPDATE users SET role = CASE email WHEN 'you@example.com' THEN 'superadmin' ELSE 'user' END WHERE email IN ('you@example.com', 'them@example.com')"

Then suspend them from the customers page. A change made by hand writes no activity entry.

Configuration

KeyDefaultWhat it does
access.permissionsfour permissionsEvery permission a route may check.
access.defaultRole'user'The role of a new account, and of a stored role the config doesn't declare.
access.firstAccountRole'superadmin'Granted to the deploy's first verified account. false turns it off.
access.rolePermissionsuser, staff, superadminEach role and what it holds.
access.planPermissionsinsights:read on every paid planWhat each plan sells. Needs an entry per plan, plus free.

Good to know

  • A misspelled role in users.role reads as defaultRole and grants nothing, so a typo in a manual promotion looks like nothing happened.
  • A role change applies on the account's next request. The browser keeps showing the old navigation until its session refreshes. To sign the account out as well, call revokeSessions(event, userId).
  • scripts/seed/users.mjs(Licensees only) names roles in plain JavaScript. test/unit/default-role.test.ts(Licensees only) fails when it seeds a role the config no longer declares, so rename roles there too.
  • A string in access.planPermissions must never also be in orgs.permissions; the build fails if it is.
  • GET /api/users answers 400 to a filter value it doesn't know, such as a role the config doesn't declare, rather than ignoring it.

A link with a lock opens the kit’s private repository, which every licensee can read.