Skip to content

feat(admin): deployment-admin dashboard (instance operator surface)#4437

Open
viktormarinho wants to merge 9 commits into
mainfrom
viktormarinho/deployment-admins-dashboard
Open

feat(admin): deployment-admin dashboard (instance operator surface)#4437
viktormarinho wants to merge 9 commits into
mainfrom
viktormarinho/deployment-admins-dashboard

Conversation

@viktormarinho

@viktormarinho viktormarinho commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What is this contribution about?

Adds an instance-level operator dashboard at /_admin, gated by the DEPLOYMENT_ADMIN_EMAILS env allowlist (not a per-org role), for debugging user issues and fixing org membership: search/list users, impersonate a user, list orgs with member counts, and add a member to any org — with an impersonation indicator in the account popover. The raw Better Auth admin plugin (/api/auth/admin/*) is fenced off so a pushed adminUserIds id can't call set-role/set-user-password directly (everything routes through the curated /api/_admin/* handlers, which call auth.api.* in-process); the surface is mounted under _admin because an underscore is illegal in an org slug and can't shadow a real org. Impersonation and member-add are audited via PostHog, thrown better-auth APIErrors are mapped to their real HTTP status instead of 500, and shared helpers (getInitials/formatDate/isAlreadyMemberError, plus a NoPermissionState action slot) remove duplicated logic.

Screenshots/Demonstration

UI-affecting (admin dashboard + impersonation pill). Screenshots to be added.

How to Test

  1. Start the app with DEPLOYMENT_ADMIN_EMAILS=you@example.com and sign in as that (verified) user.
  2. Open the account popover → "Admin Dashboard"; confirm Users and Organizations tabs load, search works, and "Impersonate" switches the session (amber "Impersonating" indicator appears, "Stop impersonation" restores you).
  3. As a non-allowlisted user, visit /_admin and confirm the "restricted to deployment admins" gate; confirm POST /api/auth/admin/set-role returns 404. E2E: bun test packages/e2e/tests/deployment-admin.spec.ts (needs the Postgres+NATS rig).

Migration Notes

No DB migration. New env var DEPLOYMENT_ADMIN_EMAILS (CSV of operator emails) — unset means no deployment admins. Revocation takes effect on next restart.

Review Checklist

  • PR title is clear and descriptive
  • Changes are tested and working
  • Documentation is updated (if needed)
  • No breaking changes

Summary by cubic

Adds an instance-level deployment admin dashboard at /_admin, gated by DEPLOYMENT_ADMIN_EMAILS, for operators to search users, impersonate, view orgs, and add members. Secures the surface with CSRF Origin checks, fences raw better-auth admin endpoints, forwards real APIError statuses, and exempts /api/_admin/* from org SSO.

  • New Features

    • Dashboard: Users (search by email/name, impersonate with indicator and one‑click stop) and Organizations (list with member counts, add members with a role).
    • API: /api/_admin/me, /api/_admin/users?searchValue&limit, /api/_admin/orgs?search&limit, POST /api/_admin/orgs/:orgId/members, POST /api/_admin/impersonate.
    • UX: "Admin Dashboard" in the account popover; stop‑impersonating calls the better-auth endpoint directly.
  • Bug Fixes

    • Access/security: allowlist via DEPLOYMENT_ADMIN_EMAILS with email verification checked after the allowlist (non‑allowlisted get 403; allowlisted unverified get email_not_verified), reject untrusted Origin on mutating routes (CSRF), fence /api/auth/admin/* (except stop-impersonating), mount ADMIN_API_PREFIX before :org, and exempt /api/_admin/* from org SSO.
    • Errors/auditing: map better-auth APIError by shape (name === "APIError" + numeric 4xx/5xx) to forward status/message/code (never cause); durable stdout + PostHog events for impersonate and member‑add.

Written for commit 0d52f1d. Summary will update on new commits.

Review in cubic

viktormarinho and others added 9 commits July 10, 2026 15:27
Instance-level operator dashboard gated by DEPLOYMENT_ADMIN_EMAILS (not a
per-org role): list/search users, impersonate, list orgs with member counts,
add a member to any org. UI at /_admin with an impersonation indicator in the
account popover.

Security posture (from the PR review):
- Fence the raw Better Auth admin plugin (/api/auth/admin/*) so a pushed
  adminUserIds id can't reach set-role/set-user-password directly — everything
  goes through the curated /api/_admin/* routes (auth.api.* in-process). Only
  stop-impersonating is allowed through.
- Mount under /_admin (underscore is illegal in an org slug) so it can never
  shadow a real org's /:org surface.
- Audit impersonate + member-add via PostHog; map better-auth APIError to its
  real status instead of 500; handle stop-impersonation failure in the UI.

Dedup: shared getInitials/formatDate/isAlreadyMemberError helpers and a
NoPermissionState action slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A freshly-impersonated e2e target is emailVerified:false, so
requireDeploymentAdmin returns 401 (email_not_verified) before the allowlist
403 branch. Assert the invariant that actually holds — the impersonated session
is blocked by the middleware (401 or 403), never allowed through — instead of
pinning 403.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
better-call ships in multiple copies, so the APIError thrown by better-auth's
plugins isn't identical to the class imported from better-auth/api — the
instanceof check missed and addMember-to-a-bad-org 500'd instead of 400.
Duck-type on a numeric 4xx/5xx statusCode instead. Fixes the e2e
add-member-nonexistent-org assertion (was masked in the prior run by the
serial describe stopping at the earlier re-impersonate failure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
That test was skipped in the prior run (serial describe stopped at the earlier
failure), so it never actually exercised the browser sign-in. Set the Origin
header on the browser context so the sign-in POST clears Better Auth's CSRF
guard, matching newApiContext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Must-fix:
- exempt /api/_admin from SSO enforcement so an admin whose active org
  enforces SSO isn't locked out of the instance surface; mount + exemption
  now share one exported ADMIN_API_PREFIX (add endpoints without a 2nd edit)
- tighten APIError shape-match to require name==="APIError" so foreign errors
  carrying a statusCode (e.g. AI SDK) aren't relabeled; +6 unit tests
- durable stdout audit line for impersonate/member-add (PostHog-less hosts)
- reach the 409 re-impersonation guard in e2e via a 2nd allowlisted admin

Recommended:
- clamp+search /api/_admin/orgs (ILIKE name/slug) + search box, mirroring /users
- two-probe email+name user search so the search placeholder is honest
- isError states on both admin pages (was silently "no results")
- encapsulate grantDeploymentAdmin(); fence e2e now loops 6 endpoints/methods

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Encapsulating the push behind grantDeploymentAdmin() (previous commit) left
deploymentAdminUserIds imported nowhere outside auth/index.ts, so knip failed
the build job on an unused export. It's referenced only internally now
(grantDeploymentAdmin + the admin-plugin adminUserIds option), so make it
module-private rather than silencing knip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review

- Origin check (trustedOrigins) on mutating /api/_admin routes so CSRF
  safety no longer rides solely on the SameSite cookie default
- audit the real actor (impersonatedBy) on member-add; server-side
  self-impersonation guard
- move the /api/auth/admin/* fence into routes/admin.ts (deleting the
  module now breaks the app.ts import)
- project /users to the fields the dashboard renders (drop raw
  better-auth rows and the branch-dependent total)
- surface email_not_verified in the /_admin gate; extract adminFetch
- e2e: fence loop covers impersonate-user/update-user, SSO-exemption
  regression test, granted-admin impersonation pinned both ways,
  browser test asserts redirect+rows, fail-fast env probe, shared
  TEST_PASSWORD; document the global APIError mapping + _admin
  mount-order guarantees

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ymentAdmin

A random signup (typically unverified) now gets the plain 403 instead of a
misleading 'verify your email' hint — only allowlisted operators see the
email_not_verified code the /_admin gate surfaces. Caught by the browser-gate
e2e: fresh non-admin users are unverified, so the gate showed the wrong copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both survived only by accident: the allowlist-before-verification check
order had no test that could distinguish it from the reverse, and the
/users field projection (which keeps role/banned/banReason off the wire)
was only checked for presence of the target row, not its shape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant