Admin panel
Admin surface is mounted at /admin. Access is gated by the is_admin column on the users table; there's no separate admin app or additional role system.
First-admin bootstrap
The first registered user is auto-promoted when NUXT_AUTO_ADMIN_FIRST_USER=true (the default). Disable this to hand out admin manually — typically by seeding the DB or by a short SQL after bootstrap:
UPDATE users SET is_admin = true WHERE email = 'you@example.com';Features
The sidebar header shows the running version (Administration · v0.3.3) next to the app name, and the same value appears as a muted footer line on every auth page and in GET /api/ready. That's the authoritative source when you need to answer "is my upgrade live?" without leaving the browser.
Dashboard — /admin
Counts at a glance: total users, verified share, recent signups (last 7 days), number of admins.
An Account security panel below the counts offers Sign out all users — a confirmed action that invalidates login sessions in bulk (see Admin-only API). A Scope picker chooses All users or Non-admins only (leaves admin accounts working — useful mid-incident). For the All users scope, the acting admin's current device stays signed in by default; tick Sign me out too to drop the acting session as well.
Users — /admin/users
- Paginated list (up to 100 per page) with search by email / name
- Filters:
Verified / UnverifiedandAdmins / Users / All roles - Click a row to open the user detail view
- Add user — provision an account from the panel, with a choice of password mode:
- Set a password now — the account is usable immediately and marked email-verified (the admin vouches; share the credentials out-of-band).
- Send an invite — no password is set; the user receives a "set your password" link (emailed when SMTP is configured, and always shown for the admin to copy). On first use the link sets their password, verifies the email, and signs them in. The link is single-use — accepting it is the only thing that consumes it, so ending the user's sessions, suspending them or resetting their 2FA leaves it working — and it expires after 7 days. A suspended account cannot be activated with one. Logged as
admin.user.created.
User detail:
- Edit first name, last name, display name
- Toggle
Email verified— useful when a user can't receive the verify email - Toggle
Admin— but admins can't un-admin themselves from the panel - View stored language (
users.locale) - Assign per-application roles (see below) for any OIDC client that defines them
- Export data — downloads a GDPR data-portability JSON for the user (same shape as the user's own export; excludes password hashes and OIDC token values). Logged as
admin.user.exported. - Reset 2FA — clear a locked-out user's second factors so they can re-enrol: removes TOTP always, and passkeys only when the user has a password (a passwordless user's passkey is their login, so it's preserved). Ends the user's sessions. Logged as
admin.user.two_factor_reset. - Suspend / Unsuspend — a reversible, non-destructive block. Suspending ends the user's sessions, blocks new logins (password + passkey), and revokes their OIDC tokens; the account's data is kept and a Suspended badge shows on the detail page. Unsuspend lets them log in again (re-authenticating fresh). You can't suspend your own account. Logged as
admin.user.suspended/admin.user.unsuspended. - Force logout — invalidate just this user's login sessions (bumps their
token_version) without suspending or resetting anything. Forcing yourself out keeps your current device signed in (other devices drop). Logged asadmin.user.force_logout. - Delete — revokes OIDC tokens and fires the client webhook
Per-application roles
If an OIDC client declares a roles array in NUXT_OIDC_CLIENTS_JSON, the user detail page shows a Roles section — one block per role group (clients collapsed by roleGroup ?? clientId; the member clients are listed next to the group name), with the group's roles as checkboxes (or radio buttons for a single-mode group). Tick the roles to grant and press Save; each group is saved independently. The assignment is returned to every client in the group as a roles claim — see OIDC → Per-application roles.
Clients that don't define roles don't appear here. Each save is logged as admin.user.roles_updated.
Audit logs — /admin/audit-logs
All security events are persisted to the audit_log table and surfaced here:
| Event | When |
|---|---|
register | New account created |
login.success / login.failed | Password or passkey login |
logout | User ends their session |
email.verified | Verification link clicked |
password.changed | From /account |
password.reset_requested / password.reset_completed | Forgot-password flow |
password.reuse_blocked | A change/reset was rejected for reusing a recent password (when history is enabled) |
account.deleted | User deletes own account |
webauthn.login / webauthn.registered | Passkey usage |
admin.user.created | Admin created a user (set-password or invite) |
admin.user.updated / admin.user.deleted | Admin actions |
admin.user.roles_updated | Admin changed a user's per-app roles |
admin.force_logout_all | Admin invalidated every login session |
admin.client.secret_revealed | Admin revealed a confidential client's secret |
oidc.client.roles_updated | An app set a user's roles via the app API |
admin.audit.anonymized | Admin ran the orphaned-row anonymisation sweep |
admin.roles.rekeyed | Admin moved role assignments between role-group keys |
admin.user.two_factor_reset | Admin reset a user's second factors (TOTP/passkeys) |
admin.user.suspended | Admin suspended an account (blocked login) |
admin.user.unsuspended | Admin lifted a suspension |
admin.user.force_logout | Admin invalidated one user's sessions |
Each row shows the time, event, status, email, IP, reason and the user agent (browser/device) captured for the request. Filters: event type, date range, pagination. An Anonymize orphaned rows action strips PII from audit rows whose user no longer exists (leftovers from accounts deleted before deletion-time anonymisation existed) — idempotent, audited as admin.audit.anonymized.
Email & SMTP — /admin/email
Everything you need to answer "is mail actually working?" without shelling into the container.
- Connection — the running SMTP configuration (host, port, implicit TLS, from address, whether authentication is configured — never the password) plus a Test connection button that runs a live probe. On failure it shows the SMTP server's own error: the code (
ECONNREFUSED,EAUTH,ESOCKET…), the command that failed, and the raw response line. Transport security reports what the connection actually negotiated — Encrypted (implicit TLS), Encrypted (STARTTLS), …certificate not verified, or Not encrypted — measured on a real connection rather than read back from your config, alongside the configured TLS policy. An unencrypted hop is called out in red, and more strongly still when SMTP credentials are travelling with it. See Configuration → Transport security. - Template preview — renders any of the five outbound emails (verification, invite, password reset, 2FA login code, new-sign-in alert) with sample data, in any configured language. It goes through the same code path a real send uses, so your
NUXT_EMAIL_*overrides and their<VAR>_<LOCALE>variants appear exactly as recipients would see them — each part is badged built-in, overridden or locale override. The HTML renders in a sandboxed frame (scripts disabled), and the preview needs no SMTP at all. - Send test email — delivers the selected template to one address: one attempt, no retry, with the SMTP error surfaced verbatim if it fails. Pre-filled with your own address. Limited to 10 sends per 10 minutes per admin and logged as
admin.email.test_sent.
The sample payload is deliberately inert: links carry the placeholder token EXAMPLE-TOKEN-NOT-VALID and the 2FA code is a fixed 123456, so a test email is never a usable credential — safe to send to a shared inbox or paste into a ticket. Test sends are excluded from the schleuse_emails_total metric so they can't distort your delivery dashboards.
With NUXT_SMTP_HOST unset the page says so and disables the send form; the preview keeps working. Nothing on this page writes configuration — SMTP settings and templates are environment variables (configuration, email templates) and change with your deployment config.
Integration guide — /admin/integration
A copy-paste reference for downstream OIDC clients: authorize URL shape, token exchange curl, PKCE primer, scope catalogue. Read-only — mirrors OIDC provider with your configured issuer URL baked in.
Each confidential client also has a Reveal secret control that shows its derived client_secret on demand (hidden by default) so you can hand it to the app — instead of computing the sha256 by hand. Revealing is audited as admin.client.secret_revealed.
Configurator — /admin/configurator
Renders the running instance's env-var mapping as a drop-in export for another deployment target. Pick docker-compose.yml, .env, or shell export at the top; the preview updates live. Every recognised NUXT_* / NUXT_PUBLIC_* variable appears with its current value pre-filled (edit inline — changes are client-side only, never written back). Secrets (NUXT_JWT_SECRET, NUXT_JWT_PRIVATE_KEY, NUXT_SMTP_PASS) are deliberately not shown — they appear in the export as commented placeholders that the operator must fill in on the target host. Use it to bootstrap a second instance, duplicate prod → staging, or check which defaults you've drifted away from.
Strictly a generator. There is no "apply" button and no server-side POST; the page cannot change the running instance. See Configuration for the env-only configuration principle.
NUXT_OIDC_CLIENTS_JSON gets a structured editor under its raw-JSON field: one collapsible card per client with typed inputs for clientId, redirectUris (add / remove rows), grantTypes / scopes (checkbox groups with a free-text fallback for values outside the app's known set), public, webhookUrl, roles (add / remove role-name rows, with a roleMode selector — multiple / single — shown once a role exists), the role-gated sign-in controls (loginRequiresRole checkbox + loginRequiredRoles list), and secretVersion (confidential clients only — change it to rotate that client's secret). Edits re-serialise back into the raw-JSON field, so the exported Compose / .env / shell output stays in sync. If you type invalid JSON directly into the raw field the structured editor pauses at its last good state and shows a warning — no lost work.
Admin-only API
Requires an authenticated admin session.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/admin/stats | Dashboard counters |
| GET | /api/admin/email/status | SMTP config + live connection probe |
| GET | /api/admin/email/preview | Render one outbound email with sample data |
| POST | /api/admin/email/test | Send a sample email through the live transport |
| GET | /api/admin/users | List users (pagination + filters) |
| GET | /api/admin/users/:id | Single user |
| PATCH | /api/admin/users/:id | Update name / verify email / toggle admin |
| GET | /api/admin/users/:id/roles | Per-client roles: defined + assigned |
| PUT | /api/admin/users/:id/roles | Replace a user's roles for one client |
| DELETE | /api/admin/users/:id | Delete user (fires webhooks) |
| GET | /api/admin/audit-logs | Paginated audit-log entries |
| GET | /api/admin/config | Env-var catalog + current values (secrets masked) |
| POST | /api/admin/force-logout-all | Invalidate all login sessions (bumps every token_version); acting admin stays signed in unless { alsoLogOutSelf: true } |
| POST | /api/admin/users/:id/reset-two-factor | Clear a user's TOTP (+ passkeys when they have a password) so they can re-enrol; ends their sessions |
Guards: admins cannot demote themselves or delete their own account via the admin API.
Imported users
A user brought in through the provisioning API shows an extra read-only line on their detail page:
Imported from:
f81d4fae-7dec-11d0-a765-00a0c91e6bf6(sub preserved)
That is the identifier the user had in the IdP they came from. "(sub preserved)" appears when the old value was UUID-shaped and is therefore also the sub this IdP issues — meaning downstream apps keyed on it needed no re-mapping. Without the note, the user's sub changed during the migration. The field is informational only; it is not editable here, and it is included in that user's GDPR export.
Provisioning tokens have no admin UI — they live in NUXT_ADMIN_API_TOKENS_JSON, like every other piece of configuration. See Migrating users in.
What the admin panel is not
- Not translated. The admin surface ships English-only today; multilingual UI currently covers user-facing pages. Tracked on the roadmap.
- Not a configuration UI. Every operator-level setting lives in env vars (see Configuration). No admin-settable OIDC clients, no runtime-editable policies. This is deliberate — the IdP image is reproducible from compose files + env.