Skip to content

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 ​

Two ways, depending on whether a human is there to click.

From the environment. Name an address and the service creates that admin at startup, once:

yaml
NUXT_BOOTSTRAP_ADMIN_EMAIL: admin@example.com

Leave the password out and one is generated and written to the log once, on the start that creates the account:

bash
docker compose logs app | grep oneTimePassword

Sign in with it, change it, then drop the variable. Set NUXT_BOOTSTRAP_ADMIN_PASSWORD instead if you ship logs off-host — a supplied password is never logged, and it must satisfy your password policy or the service refuses to start.

This is create-only. If the address already has an account, nothing happens: no password reset, no promotion, no profile edit. Changing NUXT_BOOTSTRAP_ADMIN_PASSWORD later therefore has no effect — an env var that reset a password on every boot would be a backdoor, not a bootstrap. To change it, sign in and use the account page.

By self-registration. The first registered user is auto-promoted when NUXT_AUTO_ADMIN_FIRST_USER=true (the default). Note that a bootstrapped admin already occupies that slot, so the next person to register is an ordinary user.

From the console. docker compose exec app schleuse user create admin@example.com --admin creates an admin and prints its password. It also recovers a locked-out one: user set-password and user reset-2fa. The same user actions as the panel, audited the same way. See Console (CLI).

Closed instances

With NUXT_REGISTRATION_ENABLED=false the bootstrap variables are the only route to a first admin — the auto-promotion lives in the signup handlers, and the provisioning API deliberately cannot create administrators.

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 / Unverified and Admins / 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
  • Language — set the user's language (users.locale), which decides their transactional emails and their UI once signed in. Shown when more than one language is enabled in NUXT_SUPPORTED_LOCALES; Automatic clears it, so cookie, Accept-Language and NUXT_DEFAULT_LOCALE decide again. With a single language the stored value is shown read-only. Logged as part of admin.user.updated.
  • 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 as admin.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. Every row records who acted, and — for an action taken on someone else's account — who it was about, so a single user's full history is one filter away. The user detail page shows the most recent entries for that account and links here pre-filtered; clearing the filter returns the full log.

An admin suspending a user appears in both their trails. That is not duplication: one performed the action, the other was subject to it.

Rows from before the upgrade

target_user_id was added in a later release and is not backfilled — the subject of older admin actions lived only in free-text reason, in three different formats. On an instance that predates it, a subject's trail can look emptier than their real history, and the user detail page says so rather than showing an unqualified empty list.

EventWhen
registerNew account created
login.success / login.failedPassword or passkey login
logoutUser ends their session
email.verifiedVerification link clicked
password.changedFrom /account
password.reset_requested / password.reset_completedForgot-password flow
password.reuse_blockedA change/reset was rejected for reusing a recent password (when history is enabled)
account.deletedUser deletes own account
webauthn.login / webauthn.registeredPasskey usage
magic_link.requested / magic_link.loginSign-in link emailed / used to sign in
admin.user.createdAdmin created a user (set-password or invite)
admin.user.updated / admin.user.deletedAdmin actions
admin.user.roles_updatedAdmin changed a user's per-app roles
admin.force_logout_allAdmin invalidated every login session
admin.client.secret_revealedAdmin revealed a confidential client's secret
oidc.client.roles_updatedAn app set a user's roles via the app API
admin.audit.anonymizedAdmin ran the orphaned-row anonymisation sweep
admin.roles.rekeyedAdmin moved role assignments between role-group keys
admin.user.two_factor_resetAdmin reset a user's second factors (TOTP/passkeys)
admin.user.suspendedAdmin suspended an account (blocked login)
admin.user.unsuspendedAdmin lifted a suspension
admin.user.force_logoutAdmin 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. It reaches rows whose subject is gone as well as those whose actor is: an admin action on a since-deleted user keeps a live admin's own details and loses only the reason, which is the column that named the deleted person. See Security → erasure.

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.

A service client — one whose only grant is client_credentials — gets a different card: the authorize URL, the PKCE primer and the code exchange are replaced by the single token request it actually needs, with its configured resource= filled in. Those three would otherwise be nonsense for a client with no browser flow, complete with a redirect_uri it never registered. See OIDC → Service clients.

A client with a back-channel logout endpoint shows whether its logout is session-scoped or covers all sessions — that is, whether its ID and logout tokens carry a matching sid. It is off unless the client sets backchannelLogoutSessionRequired, and the card names the flag, so "my app gets no sid" is answerable by looking at the client rather than reading the spec. See OIDC → Session-scoped logout.

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.

The page speaks the admin's language throughout: the variable descriptions and the category headings are translated into all four bundled locales, and the exported .env carries its comments in that same language. Variable names, values and the # ── category ── separators stay as they are — they are configuration, not prose. A variable whose translation is missing falls back to the English text from the catalogue, so nothing can render blank.

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 — client_credentials is offered here, and a client that declares only that is a service client: remove every redirect row, it has no browser flow), public, 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 — these select roles rather than declaring them, so a name typed there must also exist in roles), 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.

Fields the editor has no input for — roleGroup, metaGroup, pkceExempt, and anything a newer Schleuse adds — are carried through untouched. They stay invisible on the page but survive the round trip, so editing one client cannot quietly strip configuration from another. (Earlier builds dropped them: one edit anywhere in the list removed them from every client in the export, which silently revoked role-gated access and broke PKCE-exempt clients on the next deploy. If you exported a config from an affected build, compare it against your source of truth before deploying it.)

NUXT_WEBHOOKS_JSON gets its own structured editor: one card per subscription with its id, target, the audit events as checkboxes grouped by prefix, and a secret with a Generate button. Each card has a Send test button that delivers a signed webhook.test event using the configuration this instance is running — not what is typed in the form — and reports the receiver's status, duration and the start of its body. Secrets are masked here and in every export, exactly like a client secret. See Webhooks.

Below the client editor sits + New service client (machine-to-machine) — a guided form for the case with the most ways to get it wrong. It asks four things: the service's name, the API it calls, what it may do there, and the token lifetime. From that it writes both NUXT_OIDC_CLIENTS_JSON (one grant, no redirect URIs, confidential, pinned to that API with allowedResources — which every JWT-AT client now needs, audiences being deny-by-default) and NUXT_OIDC_RESOURCE_SERVERS_JSON — merging the permission names into an audience that already exists rather than adding a second entry — and generates a 32-byte client secret.

The generated secret is shown once

Every other secret on this page is masked, because the Configurator never echoes what it read from the server. A secret it just generated is the exception: it appears in the export, in the clipboard, and nowhere else. Copy it when you insert it — reloading the page does not bring it back.

The form refuses what the server would reject anyway, with the reason: a duplicate client id, an audience that is not an absolute URI, and — the usual trip-up — an API permission named after one of the identity provider's own scopes (openid, email, profile, offline_access, roles). 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.

App metadata on the user detail page ​

Below the role controls, the user detail page lists every app metadata namespace — one card per metaGroup ?? clientId, showing the member clients and the key/value pairs stored for this user. Namespaces that already hold data always appear (including one orphaned by a metaGroup change, so it can be cleaned up); configured-but-empty ones are offered for seeding while the store is enabled.

You can edit a value, add a key and remove a key; changes are saved by the same Save changes button as the profile and roles. Two things to know before you do:

  • The values are raw and owned by the application. Schleuse does not know whether a value is a flag, a number or JSON — a malformed one may break that app. You are a second writer alongside it, and the app may overwrite your correction on its next write.
  • Your edits are audited (admin.user.metadata_updated) with the namespace and the key names — never the values, which may hold personal data the audit log has no reason to duplicate. The application's own writes are not audited.

Admin-only API ​

Requires an authenticated admin session.

MethodPathPurpose
GET/api/admin/statsDashboard counters
GET/api/admin/email/statusSMTP config + live connection probe
GET/api/admin/email/previewRender one outbound email with sample data
POST/api/admin/email/testSend a sample email through the live transport
GET/api/admin/usersList users (pagination + filters)
GET/api/admin/users/:idSingle user
PATCH/api/admin/users/:idUpdate name / verify email / toggle admin
GET/api/admin/users/:id/rolesPer-client roles: defined + assigned
PUT/api/admin/users/:id/rolesReplace a user's roles for one client
DELETE/api/admin/users/:idDelete user (fires webhooks)
GET/api/admin/audit-logsPaginated audit-log entries
GET/api/admin/configEnv-var catalog + current values (secrets masked)
POST/api/admin/force-logout-allInvalidate all login sessions (bumps every token_version); acting admin stays signed in unless { alsoLogOutSelf: true }
POST/api/admin/users/:id/reset-two-factorClear 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.

A small but polished sidecar identity provider. · llms.txt