Identity

Users

Stores your app's users, lets staff manage them and lets each user change their own name, email and password.

Users keeps the people who have an account in your app. It lets staff find, add, edit and delete them in the admin area, and lets each signed-in user change their own name, email address and password. Install it in any app where people sign in; it installs Auth, which handles the sign-in itself. For the pages where users make those changes, add Account.

The package is @frigear-nu/users. It owns the users table. Who is staff is decided by permissions that roles grant, never by the module itself.

What you get

  • Admin area (opt-in, settings.admin, needs Admin): a users list at /admin/users, a page per user with tabs that other modules can add to, and a New users card and stat on the dashboard.
  • Pages (with settings.verifyEmailChange): /auth/verify-email-change and /auth/revert-email-change, which the email change links open.
  • Server routes: self-service routes under /api/users (on by default) and staff routes under /api/admin/users (opt-in, settings.admin).
  • Composables and helpers: requireUser and getUser for your server routes, and helpers for staff routes of your own (on by default).
  • Permissions: profile:read and profile:write for every signed-in user, and users:* for staff (with settings.admin).
  • Emails: a confirmation link for a new address, a revert link to the old one, and a password change notice (on with the matching feature).
ServerClientMobile
Installed for you:@frigear-nu/module-kit, @frigear-nu/auth and @frigear-nu/notify (email and password change notices).You add:@nuxthub/core.Optional:@frigear-nu/authorization (roles, which give staff their permissions), @frigear-nu/admin (the admin pages), @frigear-nu/menu (the admin menu item), @frigear-nu/widget (tabs and the dashboard widget) and @frigear-nu/mcp (with settings.mcp).

Quick setup

Install Users

pnpm add @frigear-nu/users @nuxthub/core

Add it to nuxt.config.ts

This turns on user management for staff and confirmed email changes. The admin role is given every permission, including the users:* ones:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxthub/core', '@frigear-nu/authorization', '@frigear-nu/users'],
  authorization: {
    roles: {
      admin: { label: 'Admin', grant: ['*'] },
    },
  },
  users: {
    settings: {
      admin: true,
      verifyEmailChange: true,
      profilePath: '/account/profile',
    },
  },
})

Generate the migration

Without a migration, the database has no users table and nobody can sign up.

Users registers the users table, and Auth and other modules add columns to it. Generate a migration and commit it:

pnpm db:generate

See Database.

Give someone the admin role

Without a role that holds the users:* permissions, nobody can manage users. Every staff request is denied until a role grants it.

Staff access is a permission granted through a role. Assign the admin role to your first user in the Authorization admin, or give it by email through authorization:roles. To let a smaller role in, grant it only what it needs:

nuxt.config.ts
authorization: {
  roles: {
    coordinator: { label: 'Coordinator', grant: ['users:read', 'users:list'] },
  },
},
What you should see: signed in as a user without users:list, GET /api/admin/users answers 403. With Admin installed, an admin sees the users list at /admin/users.

Options

Options go under the users key.

OptionTypeDefaultPurpose
settings.adminboolean | { page?: string | boolean }offUser management for staff: API, an admin page and a New users widget.
settings.admin.pagestring | booleantrueThe admin page path. false registers the API only.
settings.verifyEmailChangebooleanfalseA new email address takes effect only after its owner confirms it.
settings.profilePathstring–Where a confirmed email change lands (?email-changed=1).
settings.mcpbooleanfalseExpose the get-my-profile MCP tool. Installs @frigear-nu/mcp. See MCP.

settings.admin. Who may do what is decided by the users:* permissions, which no role holds by default. The page needs @frigear-nu/admin. Without it, only the API is registered, with a warning.

settings.admin.page. true is the admin route of Admin plus /users, for example /admin/users.

settings.verifyEmailChange. The emailed link opens a page where the signed-in owner presses Confirm. The old address then gets a notice with a link to undo the change. Off, the change applies immediately. See Email change.

settings.profilePath. Without it, the confirm page shows the result itself.

Server

Helpers for your own server routes. They are auto-imported:

// The signed-in user, or 401. `sessionOnly` also refuses bearer credentials (403 `session_required`).
const user = await requireUser(event, { sessionOnly: true })
const maybeUser = await getUser(event) // the user or null; never throws

They say who is calling and nothing more. To decide what the caller may do, call authorize(event, permission), which also returns the principal.

Anything that changes who the user is or how they sign in must pass { sessionOnly: true }. See sessionOnly.

The module registers the user store that Auth uses (AuthUserStore: findById, findByEmail, create, update, remove, changeId). It reads the database on every call, so a role change applies to the next request.

Admin helpers

For staff routes of your own below /api/admin/users:

// Checks the permission of an action (see the table below). Throws 401, or 403 `session_required`,
// `insufficient_scope` or `forbidden`. Returns the signed-in user.
await authorizeUsersAdmin(event, 'view' | 'list' | 'create' | 'update' | 'delete' | 'manage')

// Authorizes `action`, then loads `/api/admin/users/:id/...` and checks the permission on that user.
// Throws 401 / 403 before it looks anything up, then 400 / 404, then 403 for a policy or target protection.
// Returns { actor, principal, target }.
const { actor, target } = await loadUsersAdminTarget(event, 'view' | 'update' | 'delete' | 'manage')

// A route another module adds below `:id/` checks its own permission. Target protection still applies to writes.
await loadUsersAdminTarget(event, 'update', { permission: 'subscriptions:manage' })

// Whether the caller may do a write to this user: the permission and target protection. For the `can` flags of a response.
await canUsersAdminTarget(event, 'update', target)

USERS_PERMISSIONS maps each action to its permission, and USERS_READ_PERMISSION and USERS_MANAGE_PERMISSION are the two a module most often needs.

Permissions

With settings.admin, the module registers six permissions for staff. No role holds them by default. Grant them through roles in your authorization.roles: admin: ['*'] covers all six, because none is sensitive. Roles and requirements shows how.

PermissionLets the rolesessionOnlyCredentials
users:readSee one user: profile, sign-in methods and what other modules show on the page.noSessions, and tokens that hold it.
users:listSearch the user directory and see the sign-up numbers.noSessions, and tokens that hold it.
users:createCreate users and email them a sign-in link.noSessions, and tokens that hold it.
users:updateChange a user's name.noSessions, and tokens that hold it.
users:deleteDelete users and their sign-in methods.yesSessions only.
users:manageChange or verify a user's email address, and email them a sign-in link.yesSessions only.

Every signed-in user also holds profile:read and profile:write (read and change their own name, email and avatar). These are implicit, so they work even without Authorization.

A sessionOnly permission is never held by a token, so staff cannot give one to an API token or an OAuth application. Anything that touches how someone signs in is behind users:manage: changing a user's email, marking it verified, creating a user as verified, and resending a sign-in link.

Target protection

A staff member can only change a user who holds no permission they lack. So a coordinator who holds users:update cannot edit or delete an admin, even though they may edit other users. The rule applies to every write on another user (update, delete and manage, and routes other modules add with loadUsersAdminTarget). It does not apply to reads or to the staff member's own account.

  • The target's permissions count in full. Their roles, default roles, auto roles and authorization:roles roles, plus the implicit permissions. A sensitive permission counts by name.
  • The actor holds a permission through a role or because authorization:before allows it.
  • A refusal is 403 forbidden. Its data.missing lists the permissions the actor lacks.
  • No hook turns it off. It is not a policy. It stays in force whatever your policies and hooks do.

Reading users with a token (users:read)

With settings.admin, another app can read your user directory, for example to sync users. An API token or access token that holds users:read may call GET /api/admin/users/:id. A token that holds users:list may call GET /api/admin/users. Every other admin route needs a session.

  • Staff only, by what the user holds. A token carries a permission only when its user could use it. A user without users:read cannot pick it for an API token, and OAuth Server leaves it out of their tokens and shows it as unavailable on the consent screen.
  • And on every request. The token's frozen permissions and the user's roles are both checked. A token whose user is no longer staff is refused (403 forbidden).
  • A token without the permission gets 403 insufficient_scope.
  • A wildcard scope such as users:* covers the non-session permissions only, so it never gives a token users:delete.
  • GET /api/admin/users/:id answers a token with can.update and can.delete set to false.

Extend the admin user page

This section is for module authors. A module adds its staff routes, a tab and an overview card to the admin user page in one call from its setup():

import { extendUsersAdmin } from '@frigear-nu/users/utils'

await extendUsersAdmin(nuxt, {
  routes: [{ method: 'get', path: 'payments', handler: resolver.resolve('./runtime/server/routes/payments.get') }], // /api/admin/users/:id/payments
  tab: { /* TabDefinition of @frigear-nu/widget */ }, // area `admin.users`
  widget: { /* WidgetDefinition */ }, // area `admin.users.overview`
})

The tab and widget need @frigear-nu/widget and @frigear-nu/admin. Without them they are skipped. Routes are always registered.

Emails

Email change and password change notices go through Notify. With Email Templates installed, they are rendered from the module's Vue templates: users-verify-email-change, users-email-changed and users-password-changed. Override one by adding app/emails/<name>.vue.

App

The admin pages are a users list, a page per user and two dashboard cards (New users). They follow the list and detail page patterns and forms and actions.

useUserActions(). It owns the staff actions, so the list and the user page offer the same ones.

  • create() opens the UserCreateSlideover (a FormSlideover). It resolves with the created user or false.
  • remove(user) confirms with useConfirmDialog, deletes, shows a toast and refreshes.
  • menu(user, { extra, onRemoved }) returns the RowMenu groups. Use extra to add a group of your own between the sign-in link and Delete.

The admin user page

The admin user page (<admin page>/:id) is a tab view over the admin.users tab area of Widget, which this module owns.

  • It registers an Overview tab with the profile form and account facts, and an admin.users.overview widget area at its top.
  • Subscriptions adds a Membership tab and Payments a Payments tab.
  • Each tab receives the response of GET /api/admin/users/:id as a user prop and may emit refresh.
  • Without Widget, the page shows the Overview tab alone.
Hiding a tab is not access control. Each tab's API calls must authorize with a permission, through loadUsersAdminTarget.

What staff can change:

  • Name. With users:update.
  • Email and verification. With users:manage, which is session only. An email changed by staff is marked unverified, unless staff mark it verified in the same request.
  • Roles are not here. Staff assign and remove roles in the Authorization admin, with the roles:assign permission. See Roles.
  • Passwords are never returned.
  • A host that grants roles by email (an allowlist) judges the role the account has after the change. Let only users who may assign roles change an address that is on an allowlist.
  • Creating a user can email them a sign-in link, which also verifies the address.
  • Deleting a user also removes everything Auth keeps for them: tokens, passkeys, reset codes and sign-in links.

With Menu and the admin page on, an item users-admin ("Users", order 10) is added to the admin.nav menu.

Email change

With verifyEmailChange, the links in the two emails open pages. Opening a page changes nothing, because mail scanners and link previews follow links too. Only the button on the page applies the change, with a POST.

/auth/verify-email-change?code= is emailed to the new address. It reads the request through GET /api/users/email-change.

  • Signed out, while the link still works, it redirects to the sign-in page. It passes ?redirect= (this URL, code included) and ?reason=users.emailChange. The sign-in page says why you have to sign in (users.emailChange.signInReason) and returns here afterwards. The code is not used up on the way.
  • Signed in as another account, it says so and offers to sign out.
  • Signed in as the owner, it shows the old and new address and a Confirm new email address button. That button posts to /api/users/email-change/confirm. It then goes to profilePath?email-changed=1, or shows the result when profilePath is not set.

/auth/revert-email-change?code= is emailed to the old address after the change. It shows both addresses and a Restore button. It works signed out, because whoever changed the address may have locked the owner out; the code is the proof. The result shows on the page.

Both pages use the AuthCard of Auth and need @nuxt/ui. Without @nuxt/ui, only the API is registered, with a warning. Your app can replace either page by declaring its own page at that path.

Roles

The module does not read or write a role field. The old role is gone from the admin requests and from AdminUserDto. A user holds several roles, stored by Authorization in authorization_user_roles, together with the default, auto and hook roles. Authorization still adds a users.role column in this release, only so existing values can be moved. Nothing evaluates it, so removing a role there changes nothing.

  • Assign roles in the Authorization admin, or from code. See Roles and requirements and the admin. Moving existing role values is covered in Migrate to the new access control.
  • The users list still filters by role and shows each user's stored roles, when Authorization is installed. Default and auto roles are held without a stored row, so the filter does not find them.
  • Create users without a role. POST /api/admin/users takes no role. Assign one afterwards.

Removed hooks

The users:admin:authorize and users:admin:roles hooks, and the users:admin:* scope checks that came with them, are gone. A listener for one is never called. Grant the permissions to roles instead, and use authorization:before for a rule that has to override roles. Migrate to the new access control lists each hook and its replacement.

Routes

Routes the module registers, and who may call them. Paths come from usersApi, so app code and tests never spell them out. Import it from @frigear-nu/users/api in another module's app code, because that entry carries no build-time imports. @frigear-nu/users/utils exports it too.

import { usersApi } from '@frigear-nu/users/api'

await $fetch(usersApi.me.profile, { method: 'PATCH', body: { name: 'Ada' } })
await $fetch(usersApi.admin.user(42), { method: 'DELETE' }) // 204

Self-service routes need a cookie session (sessionOnly). They refresh the session user afterwards. They answer the caller's profile (ProfileDto: id, name, email, avatar, emailVerifiedAt), never the stored row.

Emails are trimmed and lowercased wherever they are written. Every "already in use" check ignores case (409 conflict).

MethodPath (usersApi)AuthStatusPurpose
PATCH/api/users/me (me.profile)session only200{ name?, avatar? }. name is at most 120 characters. avatar is an http(s) URL, or null to clear it.
PUT/api/users/me/email (me.email)session only200{ email }. Returns the profile plus verificationRequired.
PUT/api/users/me/password (me.password)session only200{ currentPassword, newPassword, confirmNewPassword }. Sets the password and emails a notice.
GET/api/users/email-change?code= (emailChange)none200Read-only preview of a request (verifyEmailChange). 404 for an unknown code.
POST/api/users/email-change/confirm (emailChangeConfirm)session only, owner200{ code }. Applies the change (verifyEmailChange).
GET/api/users/email-change/revert?code= (emailChangeRevert)none200Read-only preview of a revert: { status: 'pending' | 'reverted' | 'expired', oldEmail, newEmail } (verifyEmailChange).
POST/api/users/email-change/revert (emailChangeRevert)none (the code)200{ code }. Restores the old address. Returns { email, signedOut } (verifyEmailChange).
GET/api/admin/users (admin.users)session or token with users:list200A paginated list of users.
POST/api/admin/users (admin.users)users:create (users:manage to mark it verified)201{ name, email, emailVerified?, sendWelcomeEmail? }. Returns the AdminUserDto. 409 when the email is taken.
GET/api/admin/users/new (admin.new)users:list200The newest sign-ups of the last 30 days, for the dashboard widget.
GET/api/admin/users/stats (admin.stats)users:list200{ total, newUsers, days } for the dashboard stat card.
GET/api/admin/users/:id (admin.user(id))session or token with users:read200One user: the AdminUserDto plus hasPassword, passkeys, subscription and can.
PATCH/api/admin/users/:id (admin.user(id))users:update + target protection (users:manage for email)200{ name?, email?, emailVerified? }. Returns the AdminUserDto.
DELETE/api/admin/users/:id (admin.user(id))users:delete (session only) + target protection204Deletes a user. Not yourself: 400.
POST/api/admin/users/:id/resend (admin.resend(id))users:manage (session only) + target protection200Resends the sign-in link. 501 not_configured when sign-in links are off.

PUT /api/users/me/email. With verifyEmailChange, it stores the request and emails the new address instead (verificationRequired: true). A new request expires the link of an earlier one still waiting. Whenever the address changes, here, on confirmation or on a revert, unused password reset codes and sign-in links are deleted: they went to the previous address.

PUT /api/users/me/password. A wrong current password answers 401. A failing mail never undoes the change.

GET /api/users/email-change. It answers { status: 'pending' | 'confirmed' | 'expired', signedIn, owner, newEmail, oldEmail }. oldEmail is masked unless you are the owner.

POST /api/users/email-change/confirm. The code is single use and valid for 24 hours. The route verifies the address, refreshes the session and emails the old address a revert link. It answers 403 when the request belongs to another account, 400 when the code is used or expired, and 409 when the address was taken meanwhile.

POST /api/users/email-change/revert. The code is single use and valid for 7 days. The route ends every session of that account and revokes its API tokens (unless Auth's apiTokens.revokeOnReset is off).

GET /api/admin/users. It answers { items, total, page, pageSize, hasRoles, hasSubscriptions, roles }. The query takes page, pageSize (up to 100), q, role, verified, sort and order. Items are AdminUserDto plus subscription, and the stored roles when Authorization is installed. roles lists the roles the filter offers.

AdminUserDto is an allowlist: id, name, email, avatar, emailVerifiedAt, lastLoginAt and createdAt, with dates as ISO strings. It never carries the password hash, a role or columns other modules add.

The staff routes exist with settings.admin and check the users:* permissions. Routes on one user authorize before they look the user up. So a visitor gets 401 and a denied caller 403, even for an unknown id. Only then come 400 (a malformed id) and 404. Errors carry a snake_case data.code (not_found, forbidden, conflict, ...). See HTTP routes.

The emailed links point at the pages /auth/verify-email-change and /auth/revert-email-change (see Email change). No GET changes anything.

Database

Tables the module adds. Generate and commit a migration after installing.

users

The default SQLite schema has:

  • an auto-incrementing id,
  • a required name,
  • a unique, required email,
  • an optional avatar,
  • a nullable acceptedTermsAt,
  • createdAt and updatedAt.

Other modules add columns through the Module Kit extension helpers. Auth adds password, emailVerifiedAt, lastLoginAt and sessionVersion. Roles are not read from a column: Authorization keeps them in authorization_user_roles (the old role column stays only for the data migration).

account_email_changes

Registered with verifyEmailChange. It holds one row per "change my email" request: the old and new address, the confirmation code, the revertCode and their timestamps. The table is named after Account, although Users owns it.

Translations

Override any string in your app's i18n/locales/<code>.json. The module registers da and en locale files under users.admin.* for the admin pages and users.emailChange.* for the email change pages. See overriding module strings.

Next steps

Copyright © 2026