Users
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-changeand/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:
requireUserandgetUserfor your server routes, and helpers for staff routes of your own (on by default). - Permissions:
profile:readandprofile:writefor every signed-in user, andusers:*for staff (withsettings.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).
@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:
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
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
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:
authorization: {
roles: {
coordinator: { label: 'Coordinator', grant: ['users:read', 'users:list'] },
},
},
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.
| Option | Type | Default | Purpose |
|---|---|---|---|
settings.admin | boolean | { page?: string | boolean } | off | User management for staff: API, an admin page and a New users widget. |
settings.admin.page | string | boolean | true | The admin page path. false registers the API only. |
settings.verifyEmailChange | boolean | false | A new email address takes effect only after its owner confirms it. |
settings.profilePath | string | – | Where a confirmed email change lands (?email-changed=1). |
settings.mcp | boolean | false | Expose 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.
{ 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.
| Permission | Lets the role | sessionOnly | Credentials |
|---|---|---|---|
users:read | See one user: profile, sign-in methods and what other modules show on the page. | no | Sessions, and tokens that hold it. |
users:list | Search the user directory and see the sign-up numbers. | no | Sessions, and tokens that hold it. |
users:create | Create users and email them a sign-in link. | no | Sessions, and tokens that hold it. |
users:update | Change a user's name. | no | Sessions, and tokens that hold it. |
users:delete | Delete users and their sign-in methods. | yes | Sessions only. |
users:manage | Change or verify a user's email address, and email them a sign-in link. | yes | Sessions 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,
autoroles andauthorization:rolesroles, plus the implicit permissions. A sensitive permission counts by name. - The actor holds a permission through a role or because
authorization:beforeallows it. - A refusal is
403 forbidden. Itsdata.missinglists 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:readcannot 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 tokenusers:delete. GET /api/admin/users/:idanswers a token withcan.updateandcan.deleteset tofalse.
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 theUserCreateSlideover(aFormSlideover). It resolves with the created user orfalse.remove(user)confirms withuseConfirmDialog, deletes, shows a toast and refreshes.menu(user, { extra, onRemoved })returns theRowMenugroups. Useextrato 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.overviewwidget area at its top. - Subscriptions adds a Membership tab and Payments a Payments tab.
- Each tab receives the response of
GET /api/admin/users/:idas auserprop and may emitrefresh. - Without Widget, the page shows the Overview tab alone.
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:assignpermission. 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 toprofilePath?email-changed=1, or shows the result whenprofilePathis 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
rolevalues 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 andautoroles are held without a stored row, so the filter does not find them. - Create users without a role.
POST /api/admin/userstakes norole. 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).
| Method | Path (usersApi) | Auth | Status | Purpose |
|---|---|---|---|---|
PATCH | /api/users/me (me.profile) | session only | 200 | { 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 only | 200 | { email }. Returns the profile plus verificationRequired. |
PUT | /api/users/me/password (me.password) | session only | 200 | { currentPassword, newPassword, confirmNewPassword }. Sets the password and emails a notice. |
GET | /api/users/email-change?code= (emailChange) | none | 200 | Read-only preview of a request (verifyEmailChange). 404 for an unknown code. |
POST | /api/users/email-change/confirm (emailChangeConfirm) | session only, owner | 200 | { code }. Applies the change (verifyEmailChange). |
GET | /api/users/email-change/revert?code= (emailChangeRevert) | none | 200 | Read-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:list | 200 | A 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:list | 200 | The newest sign-ups of the last 30 days, for the dashboard widget. |
GET | /api/admin/users/stats (admin.stats) | users:list | 200 | { total, newUsers, days } for the dashboard stat card. |
GET | /api/admin/users/:id (admin.user(id)) | session or token with users:read | 200 | One 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 protection | 204 | Deletes a user. Not yourself: 400. |
POST | /api/admin/users/:id/resend (admin.resend(id)) | users:manage (session only) + target protection | 200 | Resends 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, createdAtandupdatedAt.
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
- Authorization – roles and permissions, and who may assign them.
- Authorization admin – give a user a role.
- Auth –
authorize,requireUserand the principal. - Admin – the admin area the user pages live in.
- Auth and scopes – how permissions decide who is staff.