Quickback Docs

Admin Panel

User management and system administration in Account UI.

The admin panel provides a user management dashboard for administrators. It's part of the Account UI SPA — when enabled, the /admin route is included in the build.

Enabling Admin

Set auth.admin to true in your account config:

quickback/quickback.config.ts
account: {
  auth: {
    admin: true,
  },
},

This does two things:

  1. Sets enableAdmin: true on the SPA's runtime config, which unlocks the /admin routes (they guard themselves and redirect away when the flag is off)
  2. Ensures the Better Auth admin plugin is enabled on the backend

When auth.admin is false (the default), the admin surface does not exist for your project: /admin redirects away for every user — including operators — and the nav entry is hidden. The compiler always emits the flag explicitly, so an unset value is a hard false.

What It Can Do

Users — every account in the app, independent of organization:

  • View all users — Paginated list with search
  • Create users — Manually create accounts with email/password
  • Ban/unban users — Suspend or reactivate user accounts
  • Reset passwords — Generate password reset for any user
  • View sessions — See active sessions for any user

Organizations — every tenant, whether or not you belong to it:

  • List all organizations — cross-tenant, not just your memberships
  • Create an organization — you name the owner; the operator creating it is deliberately not seated (see below)
  • Seat, re-role and remove members — manage a tenant's roster without joining it

These run on /auth/v1/admin/*, emitted by Quickback because Better Auth's organization plugin cannot serve them: every one of its endpoints resolves through the caller's membership, and an operator administers organizations it does not belong to.

Users sign themselves up. Self-serve signup stays on — a new account simply has no organization, and lands on a screen saying so until someone invites or seats them. What operators control is organizations, not accounts.

Access Control

The admin panel is restricted to platform roles on the Better Auth user record: appmanager (the control plane) and sysadmin (which adds cross-tenant data access on top). Everyone else is redirected away from /admin.

role: "admin" is not a platform role. By default the compiler seeds the admin plugin with adminRoles: ["appmanager", "sysadmin"]. Migrate rows with role: "admin" to "appmanager".

A project that declares plugins: { admin: { adminRoles, roles } } gets exactly that: the declared adminRoles list and role → access-controller map are emitted verbatim (appmanager: userAc narrows the provisioning operator to no admin API at all; adminRoles: ["sysadmin"] keeps set-role / impersonate / ban for sysadmin only). The compiler never re-widens a declared role, and refuses a declaration it cannot honour.

adminRoles order is ascending privilege, and it is a ceiling. adminRoles[i] is tier i + 1; every other role (user, project-custom roles) is tier 0. With the defaults, appmanager is 1 and sysadmin is 2; a comma-separated role ranks at its highest member. Better Auth's admin plugin alone lets any role holding user:set-role grant any role that exists, so the generated src/lib/auth.ts adds a hooks.before role ceiling that returns 403 ROLE_CEILING when:

  • set-role, create-user or update-user requests a role ranked above the caller's.
  • set-role, update-user, ban-user, remove-user, set-user-password, revoke-user-session(s) or impersonate-user targets a user ranked above the caller.

Equal tier is allowed, so an appmanager can still make another appmanager (the example below). Only a sysadmin can grant sysadmin.

set-organization-member refuses to seat its caller with 403 OPERATOR_SELF_SEAT: operators are never seated by their own control-plane calls.

To grant operator access to a user:

curl -X POST https://api.example.com/auth/v1/admin/set-role \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "userId": "user_123", "role": "appmanager" }'

Why creating an organization names an owner

Better Auth's organization.create seats whoever calls it. For an operator that is an escalation rather than a convenience: the membership it grants is tenant-data access — under cms.access: "member", access to that organization's records — which is exactly what the appmanager/sysadmin split withholds.

So organization creation asks for an ownerUserId, seats that user, and never seats the caller. Self-serve creation (POST /auth/v1/organization/create) is closed by default for the same reason; a project that wants it sets plugins.organization.allowUserToCreateOrganization: true.

An operator who genuinely wants their own organization creates one naming themselves as owner — explicit, and visible in the audit trail rather than implied by whoever happened to make the call.

Requiring a second factor

Operator accounts are the highest-value targets in a deployment, and CASA / ASVS require multi-factor authentication on every administrative interface. operatorMfa enforces it:

quickback.config.ts
providers: {
  auth: defineAuth("better-auth", {
    operatorMfa: true,
    plugins: ["passkey", "admin"],
  }),
},

With it on, an appmanager or sysadmin whose session was not established by a passkey assertion or a two-factor verification is treated as a plain user for that request. Every operator surface refuses with 403 MFA_REQUIRED: the CMS and /api/v1/schema, the /auth/v1/admin/* operator routes, Better Auth's own admin endpoints (list-users, set-role, ban-user), and the sysadmin cross-tenant bypass. The Account UI keeps working, so the enrolment path is short:

  1. Sign in with a password (or magic link / OTP) as usual.
  2. Register a passkey at /account/passkeys/setup.
  3. Sign out, then sign back in with the passkey.

How it works: the compiler adds a session.amr column (RFC 8176, Authentication Methods Reference) and a session.create.before hook that stamps passkey when the session comes from /passkey/verify-authentication and mfa when it comes from /two-factor/verify-*. Nothing else sets it, and it is not client-settable. The auth middleware reads it on the session lane and copies it into the JWT fast-path claims, so bearer callers are gated identically.

Things to know:

  • Requires passkey or twoFactor. The compile fails otherwise: without a second factor to enrol, every operator would be locked out.
  • Passkey sign-in only. A user who has a passkey but signs in with a password is not MFA'd; the session has to be created by the passkey assertion.
  • Two-factor "trust this device" skips the verify endpoint, so a trusted-device sign-in does not count. Operators should not use it.
  • API keys never satisfy it. An operator's API key is a single factor; operator routes reject it with MFA_REQUIRED.
  • Existing projects get a new amr column on the auth session table on the next compile; apply the generated auth migration before deploying.

Custom Admin Domain

Give the admin panel its own subdomain with adminDomain:

quickback/quickback.config.ts
account: {
  domain: "auth.example.com",
  adminDomain: "admin.example.com",
  auth: { password: true, admin: true },
},
trustedOrigins: [
  "https://auth.example.com",
  "https://admin.example.com",
],

The admin domain serves the same Account SPA at root (/). The SPA's client-side router handles showing the admin page at admin.example.com/admin. Authentication cookies are shared across subdomains automatically via cross-subdomain cookie configuration.

CMS Access Control

When both CMS and Account UI are enabled, the Account profile shows a CMS action. It opens the sole organization directly or lets a multi-organization user choose one first. You can restrict this to the sysadmin tier only:

cms: { domain: "cms.example.com", access: "sysadmin" },

With access: "sysadmin", only the cross-tenant sysadmin tier reaches the CMS: the link is hidden in Account UI, /api/v1/schema returns 403, and custom_view CRUD is locked to userRole: ["sysadmin"]. Note that an appmanager does not get in — /account/admin (this page) is the appmanager's surface, and the CMS is not. See CMS access gate for the full model.

On this page