FeaturesDevelopersDocsChangelog
hello@givelink.ai
GGiveLink
For NonprofitsPricingCompareBlogAbout
Join the Waitlist
← All runbooks

Admin Panel — Operations

How to impersonate users, reset onboarding, test with alternate domains, edit org metadata, soft-delete users and orgs, and manage super-admin promotions. Destructive hard-deletes live in admin-hard-delete.mdx (Spec D).

Last reviewed April 18, 2026

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_log row 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_EMAILS env 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

  1. Navigate to /admin/users/<id> (or search in /admin/users).
  2. Scroll to the Actions card.
  3. Enter a reason (≥10 characters, required in production).
  4. 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 as ctx.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:

  1. Clerk revokes your impersonated session.
  2. admin_impersonations.ended_at is set.
  3. admin_audit_log captures the impersonate.end with durationMs.
  4. 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:

  1. Revokes the Clerk session.
  2. Closes the impersonation row with metadata.forced_end_reason = "max_duration_exceeded".
  3. 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_progress to DEFAULT_ONBOARDING_PROGRESS (all four flags false).
  • Writes an audit row with metadata.previous_progress and metadata.previous_status so 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:

  1. UI hidden in production — the button only renders when VERCEL_ENV !== "production".
  2. Server Action refuses in production — testWithDomainAction is the first line of defense; even an attacker who discovers the form action URL and posts directly in production is rejected.
  3. Onboarding page ignores ?devDomain in production — no super-admin OR-branch that an attacker could abuse (Opus ST4).

Flow:

  1. Enter a domain (e.g., redcross.org) in the amber Test onboarding with domain disclosure.
  2. Submit. The action resets the org's onboarding state and redirects to /onboarding?devDomain=redcross.org.
  3. The Brandfetch step uses redcross.org instead 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:

  • name
  • website
  • ein
  • brandColor
  • brandColorSecondary
  • tagline

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's callerUser.deletedAt gate — 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:

  1. Rate-limits at 5 / hour / admin (shared with soft-delete-org).
  2. Sets users.deleted_at via conditional UPDATE (refuses if another admin won the race).
  3. Calls forceCloseImpersonationsForUser(id, "target_soft_deleted") — revokes Clerk sessions for every open admin_impersonations row targeting this user, closes each DB row, sets metadata.forced_end_reason = "target_soft_deleted" on each.
  4. 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_at is 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 (the requireSuperAdmin gate 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 it
  • action — 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 on
  • impersonation_id — FK to admin_impersonations (Task 4, NH-14), set when the action happened during an impersonated session
  • metadata — JSONB with action-specific diagnostics (before, after, previous_progress, forced_impersonations, durationMs, forced_end_reason, domain, …)
  • request_ip, request_user_agent, http_path, http_method
  • created_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/
GiveLink

Generosity, elevated.

Product

FeaturesFor NonprofitsPricingChangelogBlogDocsDevelopersRoadmap

Compare

GiveLink vs ZeffyGiveLink vs GivebutterGiveLink vs GoFundMe ProGiveLink vs Fundraise UpGiveLink vs DonorboxGiveLink vs Bloomerang

Company

About

Legal

PrivacyTermsAcceptable UseCookiesFee DisclosureTrademark

Our standing commitments

  • 1.No hidden fees. Any amount the donor is asked to cover is shown with a full breakdown — platform fee and processing fee itemized — before they submit.
  • 2.Every dollar you intended reaches your nonprofit. The fees you see on this page are the fees you pay — no tips, no surprise markup.
  • 3.We never call our fee a “tip” or a “contribution.” It's a fee. We name it. We show the number.

© 2026 GiveLink. All rights reserved.