Admin Panel — Operations
This runbook covers every write-capable capability of the GiveLink admin panel. Read-only navigation and access basics are in Admin Panel — Basics.
Every destructive action:
- is a Server Action (not a tRPC mutation) — MF-P.
- is gated by
requireSuperAdmin(), which 404s non-admins and refuses any actor-smuggled session (MF-J). - writes an
admin_audit_logrow FIRST, then runs the mutation (MF-L). Even if the mutation throws afterwards, the attempt is captured. - applies conditional UPDATE with a rowCount guard to close TOCTOU races where two super-admins act on the same row simultaneously.
Prerequisites
- Super-admin access — see Admin Panel — Basics.
SUPER_ADMIN_EMAILSenv var on the deployment you are accessing.- For impersonation: Clerk actor-tokens API access (built-in to
CLERK_SECRET_KEY).
Impersonation
Impersonation is the primary tool for reproducing user-reported bugs.
It uses Clerk actor tokens — a short-lived (5 min) redemption URL
that, when clicked, opens a new session where auth().userId is the
target user and auth().actor.sub is you. Every request under that
session is server-side attributed to you for audit and Sentry.
When to use it
- A donor or nonprofit admin reports a bug that isn't reproducible on test data.
- A support request requires seeing the UI exactly as the user sees it (their campaign list, their dashboard counters, their onboarding state).
- Diagnosing why a workflow (emails, Stripe connect, Salesforce sync) is failing for one specific org.
Use impersonation as a last resort for anything that can be answered
by reading the database. Every impersonation writes a row to
admin_impersonations and an impersonate.start / impersonate.end
pair to admin_audit_log.
How to start
- Navigate to
/admin/users/<id>(or search in/admin/users). - Scroll to the Actions card.
- Enter a reason (≥10 characters, required in production).
- Click Impersonate.
You will be redirected to Clerk, then back to /home as the target user.
A sticky amber banner at the top of every dashboard page indicates you
are impersonating and provides a Return to admin button.
What changes under impersonation
auth().userId— the target user's Clerk ID.auth().actor.sub— your (admin) Clerk ID. tRPC picks this up asctx.clerkActorId; server-side Sentry sets a tag.ctx.orgId— the target's active org (Clerk's default-org selection).admin_impersonations.clerk_session_id— populated on your first authed request after Clerk redeems the token (MF-O).- All dashboard tRPC procedures still run under tenant RLS for the target's org — same guarantees as the real user session.
How to return
Click Return to admin in the banner. The flow:
- Clerk revokes your impersonated session.
admin_impersonations.ended_atis set.admin_audit_logcaptures theimpersonate.endwithdurationMs.- You're redirected back to
/admin/users/<id>.
If Clerk revoke fails, the DB row stays open — an observable divergence between Clerk and our DB. That's intentional: a stale open row is a signal to an operator that something drifted, not a lie about the session state.
Rate limits
- Soft: 10 starts / hour / admin. Exceeding fires a Sentry warning
(
Admin impersonation soft limit exceeded,level: warning,tags.admin_actor). Doesn't block. - Hard: 30 starts / hour / admin. Exceeding throws
Rate limit exceeded (30 impersonations per hour)— the request never reaches Clerk.
If you hit the hard limit during an incident and need more, raise the
Upstash counter manually via the rl:admin:imp-start-hard:<clerkUserId>
key or wait for the sliding window to drain.
Can't impersonate yourself
Attempting to impersonate your own Clerk ID throws
Cannot impersonate yourself — guards against accidental foot-gun.
Server-side max duration (MF-K)
Even if Clerk's session lifetime would allow it, impersonated sessions
time out server-side at IMPERSONATION_MAX_DURATION_SECONDS (default
1800s / 30 min). When exceeded, the next tRPC protectedProcedure call:
- Revokes the Clerk session.
- Closes the impersonation row with
metadata.forced_end_reason = "max_duration_exceeded". - Throws
UNAUTHORIZED — Impersonation max duration exceeded.
The target will see a generic error. Restart the impersonation if more time is needed.
"Sign in as org" shortcut
On /admin/orgs/<id>, Sign in as org looks up the org's owner
(the org_members row with role = "owner") and delegates to the
normal impersonation flow with that user ID. All impersonation rules
apply (rate limits, prod reason, audit log, max duration).
Use when you know the org but not the user, and the problem is owner-specific (Stripe Connect onboarding, Clerk team ownership, etc.).
If the org has no owner (orphaned), the action throws
Org has no owner — cannot sign in as org without starting any
impersonation.
Reset onboarding
On /admin/orgs/<id>, Reset onboarding:
- Sets
organizations.status = "onboarding". - Resets
organizations.onboarding_progresstoDEFAULT_ONBOARDING_PROGRESS(all four flags false). - Writes an audit row with
metadata.previous_progressandmetadata.previous_statusso the pre-reset state is recoverable.
Does not touch Clerk — Clerk owns org EXISTENCE, we own onboarding state. Does not touch Stripe Connect, campaigns, donations, or members. Resetting only re-opens the onboarding wizard.
Common scenario: a customer support request — "I hit Continue too fast and skipped the branding step, can you put me back?"
Testing signup with a different domain (devDomain override)
The onboarding wizard enriches via Brandfetch using the signed-in user's
email domain. To test the enrichment flow against a specific domain
(e.g., reproducing a bug where Brandfetch returns nothing for a
particular nonprofit), use the Test onboarding with domain helper
on /admin/orgs/<id>.
Triple-gated:
- UI hidden in production — the button only renders when
VERCEL_ENV !== "production". - Server Action refuses in production —
testWithDomainActionis the first line of defense; even an attacker who discovers the form action URL and posts directly in production is rejected. - Onboarding page ignores
?devDomainin production — no super-admin OR-branch that an attacker could abuse (Opus ST4).
Flow:
- Enter a domain (e.g.,
redcross.org) in the amber Test onboarding with domain disclosure. - Submit. The action resets the org's onboarding state and redirects to
/onboarding?devDomain=redcross.org. - The Brandfetch step uses
redcross.orginstead of the admin's email domain.
Audit: org.test_onboarding_with_domain with
metadata.domain and metadata.env.
Edit org metadata
On /admin/orgs/<id>, the Edit org metadata disclosure lets a
super-admin patch:
namewebsiteeinbrandColorbrandColorSecondarytagline
Each field is optional — empty submissions are no-ops. Empty strings
are coerced to SQL NULL. URL validation runs on website. The
action writes metadata.before and metadata.after on the audit row
so the pre-edit state is fully recoverable.
Does not touch Clerk — Clerk has no knowledge of these fields.
Soft-delete user / org
Soft-delete is reversible. It sets deleted_at = now() on the user or
org row and relies on:
protectedProcedure'scallerUser.deletedAtgate — MF-J refuses every authed tRPC call.- The dashboard layout's
captureSessionOnFirstRequest()side effect — harmless on a soft-deleted session because the soft-delete-user flow also force-closes any open impersonation sessions (NH-13).
User
On /admin/users/<id>, Soft-delete user:
- Rate-limits at 5 / hour / admin (shared with soft-delete-org).
- Sets
users.deleted_atvia conditional UPDATE (refuses if another admin won the race). - Calls
forceCloseImpersonationsForUser(id, "target_soft_deleted")— revokes Clerk sessions for every openadmin_impersonationsrow targeting this user, closes each DB row, setsmetadata.forced_end_reason = "target_soft_deleted"on each. - Appends
forced_impersonations: [ids]to the audit row's metadata.
Org
On /admin/orgs/<id>, Soft-delete org sets
organizations.deleted_at. It does NOT:
- revoke Clerk sessions for org members (each member's own
users.deleted_atis unchanged; they may still be active elsewhere). - cancel Stripe subscriptions.
- close the org in Clerk.
Soft-delete-org is a gate for downstream hard-delete (Spec D) and a temporary lock-out. Use hard-delete when permanence is required.
Restore
On the user/org detail page, the Restore button nulls out
deleted_at. No rate limit (restore is rare and not abuse-prone).
Conditional UPDATE guards against double-restore races.
Super-admin promote / demote
On /admin/users/<id>, Promote to super-admin / Demote from
super-admin toggles users.is_super_admin. Guardrails:
- Refuses to demote the last active super-admin — pre-check counts
active super-admins (excluding the target and soft-deleted admins). If
zero, throws
Cannot demote the last active super-admin. No audit row written (therequireSuperAdmingate passed but the mutation was never called). - Never touches
super_admin_bootstrapped_at— that column tracks the original bootstrap event (SUPER_ADMIN_EMAILS) and is used for the bootstrap-once invariant.
Audit actions: user.super_admin.promote / user.super_admin.demote.
Audit log
Every super-admin action writes a row to admin_audit_log. Rows are
cross-tenant (no RLS) and append-only. Snapshot columns
(admin_email_snapshot, target_email_snapshot,
target_org_name_snapshot) preserve context after hard-deletes.
Columns of interest:
admin_user_id,admin_email_snapshot— who did itaction—impersonate.start,impersonate.end,user.soft_delete,user.restore,org.soft_delete,org.restore,org.edit_metadata,org.reset_onboarding,org.test_onboarding_with_domain,user.super_admin.promote,user.super_admin.demote, …target_user_id,target_org_id— who it acted onimpersonation_id— FK toadmin_impersonations(Task 4, NH-14), set when the action happened during an impersonated sessionmetadata— JSONB with action-specific diagnostics (before,after,previous_progress,forced_impersonations,durationMs,forced_end_reason,domain, …)request_ip,request_user_agent,http_path,http_methodcreated_at
View the last 50 entries on any user or org detail page, or query
admin_audit_log directly for cross-tenant investigations.
Common support scenarios
"I can't see my campaigns"
Impersonate → navigate to /campaigns → reproduce → check
auth().actor.sub is set and the tRPC campaigns.list returns for
the right tenant. If the user is actually soft-deleted, every authed
tRPC call throws UNAUTHORIZED — Account disabled (MF-J). Restore via
the user detail page.
"My logo isn't loading"
Use Test onboarding with domain against the user's primary domain and watch the enrichment stream. If Brandfetch returns null, the customer needs to manually upload the logo via Settings → Branding.
"I accidentally skipped onboarding"
Reset onboarding on the org detail page. The wizard re-opens on next dashboard render.
"An admin left the company, please remove their super-admin"
Promote/demote on the leaver's user detail page. The last-admin guard
prevents accidentally locking everyone out. Don't forget to also
remove their email from SUPER_ADMIN_EMAILS if it's there — that env
var is the bootstrap fallback.
"A donor hit Submit twice and we have a duplicate donation"
This runbook does NOT cover donation refunds — see payments runbooks. Impersonation is useful if you need to view the donor's confirmation screen to confirm the duplication was double-submit vs intentional.
Troubleshooting
"Impersonation is not available in dev-bypass mode"
You're running locally on localhost or demo.givelink.ai with
DEV_AUTH_BYPASS=true. The synthetic admin (user_dev_bypass) has no
Clerk identity, so actorTokens.create would 401. Sign in via
app.givelink.ai with a real super-admin to test impersonation, or use
a preview deploy.
"Cannot impersonate an invited user who has not completed signup"
The target is an invite stub — a placeholder users row created by
inviteMember with ID user_invite_<uuid-fragment>. Clerk has no
record of them yet. Ask the invitee to accept their invitation first;
impersonation will be available once signup completes. On the user
detail page the Impersonate form is replaced by an italic note for
these users — the server-side guard is belt-and-suspenders.
Impersonate button redirects to /sign-in instead of the target's dashboard
Clerk token redemption failed or expired. Tokens live 5 min — try again within 5 min of minting. Check the Network tab for the Clerk redemption response.
"Impersonation max duration exceeded" after only a few minutes
Check IMPERSONATION_MAX_DURATION_SECONDS in Vercel env. It defaults
to 1800s (30 min) but can be lowered per environment.
Banner doesn't show up
The banner renders inside the ClerkProvider-wrapped content. If
isClerkEnabled is false (dev-bypass or missing Clerk keys), the
banner is skipped entirely. Check /api/health (or similar) to
confirm Clerk is active for the host you're on.
"Cannot demote the last active super-admin"
You are trying to demote the only remaining admin. Promote someone
else first, then demote. Or add another email to SUPER_ADMIN_EMAILS
and redeploy — the bootstrap flow will pick them up on next login.
Edit org metadata "Not a valid URL"
The website field uses Zod's .url() validator — must include
scheme (https://). Leaving it blank clears the column (set to NULL).
Test-with-domain button missing in production
Correct — the button is gated on !isVercelProduction() client-side,
isVercelProduction() refusal server-side, AND the onboarding page
ignores ?devDomain in prod. If you need to test a domain against
production data, run the enrichment API locally with a clone of the
production DB. Do not bypass the gates.
References
- Spec:
docs/superpowers/specs/2026-04-18-spec-c-admin-impersonation.md - Implementation plan:
docs/superpowers/plans/2026-04-18-spec-c-admin-impersonation.md - Admin Panel — Basics (access + navigation)
- Hard-delete (Spec D):
admin-hard-delete.mdx(to be created in Spec D) - Code:
src/lib/admin/,src/app/(admin)/admin/