Skip to content

Admin

Platform administrators are users whose role is admin — an axis independent of organization roles. An organization owner is not an admin, and an admin has no implicit membership anywhere. The same role list drives Better Auth’s admin({ adminRoles }), the API’s requireAdmin middleware and the /admin route guard (a universal load, like the session guard, redirecting non-admins to /app).

Terminal window
ADMIN_PASSWORD='' bun run admin:create -- --email you@example.com --name 'You'
# add --yes to promote an account that already exists

With ADMIN_PASSWORD unset the script prompts for a password. It is never read from the command line, where it would sit in shell history and in ps; and it goes through the kit’s own auth config rather than downloading a CLI, so it runs inside the api container as well as from a checkout.

Further admins are promoted from /admin/users.

/admin/users — people (Better Auth admin plugin)

Section titled “/admin/users — people (Better Auth admin plugin)”

Search, ban and unban with a reason and an optional expiry, set role, revoke sessions, impersonate. These come from the Better Auth admin plugin at /api/auth/admin/*; Director adds no procedures for them.

Impersonation sessions last one hour and cannot target other admins. While one is active the app shell shows a banner with Stop impersonating. Starting an impersonation and revoking a user’s sessions both ask for confirmation first: they are one click beside a search result, and neither can be undone.

Every organization with member and workspace counts, effective plan and current subscription (admin.organizations.list). A subscription whose seats disagrees with the member count is flagged here — seat sync swallows its failures into a log line, so this is where drift is visible before the daily reconcile repairs it. Force-cancel a subscription at period end or immediately (admin.organizations.cancelSubscription): the API asks Stripe, mirrors the answer into the local row and audits it with the admin as actor. Answers 412 when billing is not configured, 404 without a live subscription and 502 with Stripe’s message on a Stripe error.

Delete an organization (admin.organizations.delete, DELETE /api/v1/admin/organizations/{organizationId}) is the escape hatch for a tenant nobody owns any more: Better Auth needs an owner to promote one, and only an owner may delete an organization. It takes the organization’s slug as confirmSlug, refuses with PRECONDITION_FAILED while a live subscription exists, and reports how many stored objects went with it (objectsDeleted, objectsFailed). The page keeps the button disabled until the slug is typed out.

Flags live in feature_flag (a global default) and feature_flag_override (per organization). Admins manage both (admin.flags.list | upsert | delete | setOverride); clients read the resolved map for an organization from system.flags (GET /api/v1/flags?organizationId=…).

In the app, the (app) layout load fetches the flags for the active organization once per session and components read flag('key') from $lib/flags. An unreachable API means “nothing is on”. Keys are stable slugs used literally in code: lowercase letters, numbers, dots, dashes and underscores.

admin.system.status reports the version — read from the root package.json, which is the single source of truth for it — the environment and runtime, uptime, counts (users, organizations, workspaces, entitling subscriptions), a database probe with migration state (journal shipped in the build vs. drizzle.__drizzle_migrations), the AI service’s /health, and which optional subsystems are configured — billing (with any unpriced plans), the email provider and sender, social sign-in and email verification. Never secrets.

The AI service answers /health with HTTP 200 and ok: false when its configuration cannot serve a request — no token check, or an OCR backend that cannot run there — with one sentence per fault in problems, which this page lists under a Degraded label rather than a green tick. It is deliberately not a 503: a restart cannot fix an environment, so the container health check asserts liveness only.

  • Admin actions on users are not in the per-organization audit log (they have no tenant); rely on Better Auth’s request logging or add a global log when needed.
  • An action an admin takes while impersonating is audited in that organization’s log with the impersonated user as the actor and the admin’s id in impersonated_by, so an operator’s change is never attributed to the person they were impersonating.
  • Flag reads are one indexed query per page load with no caching; add a short cache when flags are read on hot paths.
  • Deleting users is deliberately not exposed in the UI.