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'],
},
},
permissionslists every permission. ThePermissionandUserRoletypes come from this block, so a grant or a check naming anything else fails typecheck.rolePermissionsis what each role holds.planPermissionsis what each plan sells, withfreefor an organization that holds no grant.requirePermissionchecks 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.users.roleis a text column. Each request that checks the session reads it again, and a value the config doesn't declare reads asdefaultRole.
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.
| Permission | Opens |
|---|---|
users:read | /dashboard/customers, the organization directory, account and billing details there |
users:manage | Impersonating, suspending, editing accounts, support mails, granting plans, refunds |
blog:author | Lets the account at the blog's author address appear as the byline |
insights:read | The 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.
pendingis 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/permissionsinstead, asapp/middleware/admin.ts(Licensees only) does to keep accounts withoutusers:readoff 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:
| Action | Route |
|---|---|
| Edit name, email, role | PATCH /api/admin/users/:id |
| Suspend | POST /api/admin/users/ban |
| Impersonate | POST /api/admin/impersonate, POST /api/admin/impersonate/stop |
| Mail a password reset | POST /api/admin/users/password-reset |
| Mail a verification link | POST /api/admin/users/verification-email |
| Clear sign-in lockouts | POST /api/admin/users/unlock |
| Grant or take back a plan | POST /api/admin/billing/grant, /revoke |
| Refund a purchase | POST /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
| Key | Default | What it does |
|---|---|---|
access.permissions | four permissions | Every 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.rolePermissions | user, staff, superadmin | Each role and what it holds. |
access.planPermissions | insights:read on every paid plan | What each plan sells. Needs an entry per plan, plus free. |
Good to know
- A misspelled role in
users.rolereads asdefaultRoleand 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.planPermissionsmust never also be inorgs.permissions; the build fails if it is. GET /api/usersanswers 400 to a filter value it doesn't know, such as arolethe config doesn't declare, rather than ignoring it.
A link with a lock opens the kit’s private repository, which every licensee can read.