Appearance
Epic 24 — Admin Impersonation
A tool for admins to debug user-reported issues and QA role-specific flows by logging in as another user — without disturbing their own admin session. The impersonation must (a) happen in a brand-new browser tab that behaves like a completely separate session from the admin's, (b) never leak into the admin's main tab, and (c) leave a complete, immutable audit trail of every action performed under the impersonated identity.
Design constraints
- Tab-isolated session. The admin's original tab keeps using its
localStorage-persisted access token. The impersonation tab uses an entirely separate token stored insessionStorageonly — so closing the tab ends the session, and the token never reaches any other tab or the admin's primary tab. - Distinct token scope. The impersonation JWT carries
scope: "IMPERSONATION"plus bothsub(target user) andimpersonatorId(admin). The backend rejects this token from any endpoint that would mutate sensitive identity (password change, 2FA setup, email change, deletion, payment) and stamps every other resolver call with the impersonation session id for audit. - Audit-by-default, admin-visible only. A session row is created at start, every GraphQL operation during the session is logged with operation name + variable hash, and a row is closed at end (manual stop, tab close, or expiry). The audit trail is surfaced to other admins via the impersonation admin tab — the target user is never notified and sees no record of the session. No notifications, no inbox entries, no "logged in from new device" emails are emitted for the target.
- Visible only inside the impersonation tab. The impersonation tab shows a persistent banner at the top of every page with the impersonated user's identity and a big "End impersonation" button. The browser tab title is prefixed with
[IMPERSONATING]. None of this is visible to the actual user — it lives only in the admin's tab. - Reason required. Admin must enter a short reason (e.g. "debugging ticket #1234") before the session starts — stored on the audit row.
- Cannot escalate. Admins cannot impersonate other admins. Suspended/banned users can be impersonated (so that admins can confirm reported abuse).
- Short-lived. Sessions expire after 30 minutes by default; admin can extend once per session up to a 2 h hard cap.
- No side-effects bleeding into the user's view of their account. Notification, email, push, and "active session" channels are all suppressed for the impersonation token (see BE-IMPERSONATE-002 guards).
BE-IMPERSONATE-001 — Impersonation session model + audit tables
- [x] Done
Files:
- Edit:
prisma/schema.prisma— addImpersonationSession,ImpersonationActionLogmodels - Run:
pnpm db:migrate
Schema:
prisma
model ImpersonationSession {
id String @id @default(cuid())
adminId String
admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id])
targetUserId String
targetUser User @relation("ImpersonationTarget", fields: [targetUserId], references: [id])
reason String // required justification ("debugging ticket #1234")
startedAt DateTime @default(now())
expiresAt DateTime // startedAt + 30 min by default
extendedAt DateTime? // set once if admin extends; expiresAt is updated
endedAt DateTime? // null = still active
endedBy ImpersonationEndReason? // MANUAL | EXPIRED | TAB_CLOSED | ADMIN_REVOKED
ip String?
userAgent String?
actionLogs ImpersonationActionLog[]
@@index([adminId, startedAt])
@@index([targetUserId, startedAt])
@@map("impersonation_sessions")
}
enum ImpersonationEndReason {
MANUAL
EXPIRED
TAB_CLOSED
ADMIN_REVOKED // a higher-level admin revoked the session
}
model ImpersonationActionLog {
id String @id @default(cuid())
sessionId String
session ImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
operationType String // QUERY | MUTATION
operationName String // GraphQL operation name (e.g. "updateTalentProfile")
variablesHash String // SHA256 of redacted variables JSON — no PII in logs
blocked Boolean @default(false) // true if a forbidden mutation was attempted
blockedReason String?
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
@@map("impersonation_action_logs")
}Acceptance criteria:
- Both relations on
User(admin side + target side) declared with distinct relation names - Cascade delete logs when session is deleted, but sessions themselves are never user-deletable (admin-only soft state via
endedAt) - Indexes support "all sessions for this admin" and "all sessions where this user was impersonated" queries
BE-IMPERSONATE-002 — Impersonation token issuance + scoped JWT
- [x] Done
Files:
- Create:
src/services/admin/impersonation.ts—startSession,endSession,extendSession,recordAction - Edit:
src/config/context.ts— recognisescope: "IMPERSONATION"tokens - Edit:
src/graphql/schema/index.ts— add mutations + queries - Edit:
src/graphql/resolvers/admin.ts - Create:
src/middleware/impersonationAudit.ts— Apollo plugin that writes anImpersonationActionLogrow per operation - Create:
src/__tests__/services/impersonation.test.ts - Create:
src/__tests__/resolvers/impersonation.test.ts
GraphQL:
graphql
type ImpersonationStartResult {
token: String! # short-lived JWT, scope=IMPERSONATION
sessionId: ID!
expiresAt: String! # ISO
targetUser: User!
}
type ImpersonationSession {
id: ID!
admin: User!
targetUser: User!
reason: String!
startedAt: String!
expiresAt: String!
endedAt: String
endedBy: String
ip: String
userAgent: String
actionCount: Int!
blockedCount: Int!
}
extend type Mutation {
startImpersonation(targetUserId: ID!, reason: String!): ImpersonationStartResult!
endImpersonation: Boolean! # called by the impersonation tab itself
revokeImpersonation(sessionId: ID!): Boolean! # admin-only escape hatch
extendImpersonation: String! # new expiresAt
}
extend type Query {
impersonationSessions(filter: ImpersonationFilter, page: Int, pageSize: Int): ImpersonationSessionPage!
impersonationSession(id: ID!): ImpersonationSession!
impersonationActionLogs(sessionId: ID!, page: Int, pageSize: Int): ImpersonationActionLogPage!
myActiveImpersonation: ImpersonationSession # if the admin currently has one active
}
input ImpersonationFilter {
adminId: ID
targetUserId: ID
activeOnly: Boolean
from: String
to: String
}JWT shape (impersonation only):
json
{
"sub": "<targetUserId>",
"impersonatorId": "<adminId>",
"sessionId": "<impersonationSessionId>",
"scope": "IMPERSONATION",
"tokenVersion": <target user's tokenVersion at issue time>,
"iat": <issued>,
"exp": <issued + 30 min>
}Context changes (createContext):
- Parse JWT as today.
- If
scope === "IMPERSONATION":- Load the impersonation session by
sessionId. Reject ifendedAtis set,expiresAtis past, or the row was revoked. - Hydrate
userwith the target user's id and profile flags (so all resolvers run as them). - Attach
impersonation: { adminId, sessionId }to context (typed inGraphQLContext). - Touch the session row (
lastSeenAt) on each request — optional; skip if hot-path cost is too high.
- Load the impersonation session by
Resolver guards (re-used everywhere):
- New helper
requireNotImpersonating(ctx)used in mutations that must not be performed under impersonation:changePassword,enable2FA/disable2FA/verify2FA,updateEmail,deleteAccount,purchaseCasTarsBundle(and any future Stripe action),socialLoginLink,confirmLiveness- When called under impersonation: log a
blocked: trueaction row withblockedReason, throwFORBIDDEN_DURING_IMPERSONATION.
- New helper
requireAdmin(ctx)already exists — it must explicitly reject impersonation contexts (an admin impersonating a non-admin must not regain admin powers via the token).
Apollo audit plugin (impersonationAudit.ts):
didResolveOperation: ifctx.impersonationpresent, captureoperationName,operationType, redact known-sensitive keys fromvariables(passwords, tokens, files), SHA256 the result.willSendResponse: insertImpersonationActionLogrow (fire-and-forget but awaited so it cannot be silently dropped in tests).
Side-effect suppression under impersonation: The target user must NOT be able to tell that an impersonation session happened. Wire the impersonation context through to the side-effect layer:
src/services/notifications/index.ts—createNotification(userId, …): ifctx.impersonationis present, skip writing the notification entirely (never even an "admin signed in" entry).src/services/email/index.ts—sendEmail(…): skip transactional emails when called from an impersonation context. This explicitly suppresses "new sign-in from device" / "security alert" / "weekly digest" mails that would otherwise fire from actions the admin performs.- Push / web-push delivery: same suppression.
- "Active sessions" / "logged-in devices" UI (if/when added): the impersonation JWT must NOT appear in the target user's session list.
lastLoginAtand friends onUserare NOT updated by impersonation tokens.
This is enforced centrally by the service layer reading ctx.impersonation — individual resolvers do not need to special-case it. Add a regression test per channel (notifications.test.ts, email.test.ts) that calls a mutation under an impersonation context and asserts zero side-effects were emitted.
startImpersonation behaviour:
requireAdmin(ctx)— must NOT itself be impersonating (no nesting).- Reject if
targetUserId === ctx.user.sub(no self-impersonation). - Reject if target user has
isAdmin: true→CANNOT_IMPERSONATE_ADMIN. - Reject if the same admin already has an active (non-expired) session — they must end it first.
- Trim/validate
reason(1–200 chars). - Create
ImpersonationSessionwithexpiresAt = now + 30 min, capturereq.ip+req.headers['user-agent']. - Sign + return JWT.
endImpersonation behaviour:
- Only callable by an impersonation-scoped token (
ctx.impersonationpresent). - Set
endedAt = now,endedBy = MANUAL.
revokeImpersonation(sessionId) behaviour:
- Admin-only; can revoke any session. Sets
endedBy = ADMIN_REVOKED.
extendImpersonation behaviour:
- Impersonation-scoped token only. Allowed once per session. Sets new
expiresAt = now + 30 min, capped atstartedAt + 2 h. Returns new ISO timestamp. The client must re-issue a new JWT — done by also returning a refreshed token in the mutation payload (extend the type accordingly: return{ token, expiresAt }).
Acceptance criteria:
- Impersonation token used on
changePassword→ throwsFORBIDDEN_DURING_IMPERSONATIONAND anImpersonationActionLogrow withblocked: trueis written - Impersonation token used on a normal query (
me) → returns the target user's data, action log row written withblocked: false - Admin's normal access token continues to work in their original tab while an impersonation session is active
startImpersonationon an admin target → throwsCANNOT_IMPERSONATE_ADMIN- Expired token → context drops
usertonull(UNAUTHENTICATED), and an attempt to use it logs nothing (no session = no audit row) - A mutation under impersonation that would normally write a notification (e.g. a follow, a message) → notification row count is unchanged, and no email/push is sent. Regression test asserts both.
- All paths covered by tests in
src/__tests__/resolvers/impersonation.test.ts
FE-IMPERSONATE-001 — Start-impersonation action in admin user detail
- [x] Done
Files:
- Edit:
apps/app/src/pages/admin/AdminUserDetailPage.tsx— add "Impersonate" button + reason modal - Create:
apps/app/src/hooks/useStartImpersonation.ts - Create:
apps/app/src/lib/queries/impersonation.ts
Description:
- "Impersonate user" button visible on
AdminUserDetailPage(admin role only, hidden for admin targets). - Click opens a DS
Modalwith:- Big warning: "You're about to log in as <name>. Every action you take will be logged and reviewable by other admins. Sensitive actions (password change, 2FA, payments) will be blocked. The user will NOT be notified — keep this session strictly for debugging."
- Required reason
Textarea(1–200 chars). - "Start impersonation" primary button.
- On submit → call
startImpersonation→ receive{ token, sessionId, expiresAt, targetUser }. - Open a new tab via
window.open('/_impersonate/handoff#token=' + encodeURIComponent(token), '_blank', 'noopener,noreferrer'). The token rides in the URL hash (not query) so it never reaches the server log.noopener,noreferrersevers the link to the admin tab. - Show a confirmation toast: "Impersonation tab opened. Don't forget to end the session when done."
Acceptance criteria:
- Button is hidden for admin targets and for the admin's own profile row.
- Modal validates the reason field (non-empty, ≤ 200 chars) before enabling submit.
- Closing the modal without submitting does NOT start a session.
- New tab opens via
window.openwithnoopener,noreferrer. - All UI from
@castyou/design-system.
FE-IMPERSONATE-002 — Impersonation tab handoff + isolated session
- [x] Done
Files:
- Create:
apps/app/src/pages/impersonate/ImpersonateHandoffPage.tsx - Create:
apps/app/src/components/ImpersonationBanner.tsx— moved to DS in follow-up - Create:
apps/app/src/lib/impersonationStorage.ts— tab-scoped storage helpers - Edit:
apps/app/src/lib/graphqlClient.ts— prefer impersonation token when present - Edit:
apps/app/src/App.tsx— add/_impersonate/handoffroute; mount banner insideProtectedShellLayout - Edit:
apps/app/src/stores/auth.ts— read fromimpersonationStoragefirst when the tab is in impersonation mode
Tab isolation strategy:
The whole problem reduces to: how does the new tab use a totally different auth identity from the admin's original tab, on the same origin? Storage facts the design relies on:
| Storage | Per-tab? | Survives reload? | Shared across tabs? |
|---|---|---|---|
localStorage | no | yes | yes (same origin) |
sessionStorage | yes (per tab) | yes | no — even with window.open, noopener severs inheritance |
| Cookies | no | yes | yes |
| URL hash | per navigation | no | no |
The flow:
- Admin tab calls
window.open('/_impersonate/handoff#token=...', '_blank', 'noopener,noreferrer').noopeneris critical — it prevents the new tab from inheriting the admin'ssessionStorageand preventswindow.openerreach-back. - New tab boots to
ImpersonateHandoffPage. It:- Reads
window.location.hash, extractstoken. - Calls
sessionStorage.setItem('castyou_impersonation_token', token)ANDsessionStorage.setItem('castyou_impersonation_mode', '1'). - Calls
window.history.replaceState(null, '', '/discover')(or wherever the post-handoff landing is) — token is gone from the URL on the very first paint, never reaching browser history or any logging. - Fetches
meusing the impersonation token → hydrates a tab-local Zustand store (NOT the persisteduseAuthStore) with the target user's profile.
- Reads
- From this point on, the GraphQL client checks impersonation mode first:tsSo the impersonation tab never reads or writes the admin's
const impersonationToken = sessionStorage.getItem('castyou_impersonation_token'); const isImpersonating = sessionStorage.getItem('castyou_impersonation_mode') === '1'; const token = isImpersonating ? impersonationToken : localStorage.getItem('castyou_access_token');localStoragetoken. App.tsxcheckssessionStorage.getItem('castyou_impersonation_mode')very early (before the persisted auth store hydrates) and short-circuits the normal auth bootstrap path — otherwise the persisted admin user would briefly flash on screen.- The persisted
castyou-authlocalStoragekey is read-only in impersonation mode and is NEVER written to. The Zustand persistence is disabled for the impersonation tab via askipHydrationguard.
ImpersonationBanner (always visible in impersonation tabs):
- Sticky bar at the top of every screen (above
AppHeader). - Background:
bg-warning(DS semantic token to add if missing). - Content:
"You're impersonating <displayName> (<email>). Session ends in <countdown>."+End impersonationbutton +Extend 30 minbutton (only enabled if not yet extended). - "End impersonation" → calls
endImpersonationmutation, thenwindow.close()(best-effort) and clearssessionStorage. - Countdown timer updates every second; when it hits zero, the banner shows "Session expired — please close this tab" and the GraphQL client refuses further requests.
- Tab title is set via
useEffect:document.title = '[IMPERSONATING] ' + originalTitle.
Hardening:
- Clicking external links inside the impersonation tab still keeps the user in the impersonation tab — that's intentional. But any navigation to
/admin/*is blocked with a friendly notice ("Admin pages are disabled during impersonation. End the session in this tab to return to admin.") - The window-
beforeunloadhandler callsendImpersonationsynchronously vianavigator.sendBeaconso the audit row'sendedBybecomesTAB_CLOSEDeven when the tab is closed abruptly. - If the user opens the app in another tab while a normal session exists, that other tab is unaffected (different
sessionStorage). - A safety check on app boot: if
sessionStorage.castyou_impersonation_mode === '1'but no token can be parsed/decoded, redirect to a friendly "Session expired" screen and clear sessionStorage. Never silently fall back to the admin's localStorage token.
Acceptance criteria:
- Opening the impersonation tab does NOT affect the admin's original tab — both stay logged in as their respective identity.
- Closing the impersonation tab automatically fires
endImpersonation(verify via audit log row showingendedBy: TAB_CLOSED). - Reloading the impersonation tab preserves the impersonation state (because
sessionStoragesurvives reload). - Opening a fresh tab to the same app URL is NOT impersonated (because
sessionStorageis per-tab and was severed bynoopener). - The admin's profile is never visible inside the impersonation tab — even briefly during boot.
- The banner is present on every protected page; clicking "End impersonation" closes the session and the tab.
- All DS rules respected — banner lives in
@castyou/design-system.
FE-IMPERSONATE-003 — Audit log viewer (admin)
- [x] Done
Files:
- Edit:
apps/app/src/pages/admin/AdminPage.tsx— add "Impersonation" tab - Create:
apps/app/src/pages/admin/AdminImpersonationPage.tsx - Create:
apps/app/src/pages/admin/AdminImpersonationSessionDetail.tsx - Create:
apps/app/src/hooks/useImpersonationSessions.ts
Description:
- Admin tab "Impersonation" showing a paginated table of all sessions (filter by admin, by target user, by date range, active-only).
- Columns: admin → target → reason → started → ended → duration → action count → blocked count → status badge (Active / Ended / Revoked / Expired).
- Row click → session detail page: full metadata + action log timeline (operationName, type, blocked flag, time).
- Active sessions show a "Revoke" button (calls
revokeImpersonation). - A panel on the admin's own profile lists their own past sessions for quick self-review.
- Pagination per
feedback_pagination.md— page/pageSize controls below the table. - All from
@castyou/design-system.
Acceptance criteria:
- Active sessions are visually distinguishable (status badge + subtle row highlight).
- Revoke action is gated behind a confirmation dialog.
- Action log paginates independently from the session list.
- Tests cover: empty state, active vs ended rendering, revoke flow, blocked-action highlighting in the timeline.