feat(admin): login-as-user impersonation with audit log
Let super admins act as a user from the user detail dashboard for
support/debugging ("full with guardrails", audit-only).
Backend: AuthService.impersonate issues a short-lived token for the
target carrying an `act` claim (acting admin); refresh preserves it and
JwtStrategy surfaces `impersonatedBy`. Guardrails: cannot impersonate an
admin or a deactivated account; new ImpersonationGuard blocks sensitive
self-service (change own password/phone) while impersonating. New
AuditLog entity records impersonation start/stop (admin, target, ip,
time); admin endpoints POST users/:id/impersonate + .../impersonation/
stop and GET users/:id/audit.
Frontend: lib/impersonation swaps admin/impersonation tokens in
localStorage; persistent banner with exit; "Login as user" button and an
"Admin access log" tab on the detail page; logout clears impersonation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import api from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Client-side "Login as user" (impersonation) helpers.
|
||||
*
|
||||
* The admin's own tokens are stashed under `admin*` keys while the active
|
||||
* `accessToken`/`refreshToken` are swapped for the impersonation tokens, so the
|
||||
* whole app (and the axios interceptor) transparently acts as the target user.
|
||||
* Both start and stop are recorded server-side in the audit log.
|
||||
*/
|
||||
|
||||
const IMP_KEY = 'impersonation';
|
||||
|
||||
export interface ImpersonationInfo {
|
||||
targetUserId: string;
|
||||
targetName: string;
|
||||
}
|
||||
|
||||
export function getImpersonation(): ImpersonationInfo | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(IMP_KEY);
|
||||
return raw ? (JSON.parse(raw) as ImpersonationInfo) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isImpersonating(): boolean {
|
||||
return getImpersonation() !== null;
|
||||
}
|
||||
|
||||
/** Begin impersonating a user. Caller should hard-reload afterwards. */
|
||||
export async function startImpersonation(targetUserId: string): Promise<ImpersonationInfo> {
|
||||
const { data } = await api.post(`/admin/users/${targetUserId}/impersonate`);
|
||||
// Stash the admin session so we can return to it later.
|
||||
localStorage.setItem('adminAccessToken', localStorage.getItem('accessToken') ?? '');
|
||||
localStorage.setItem('adminRefreshToken', localStorage.getItem('refreshToken') ?? '');
|
||||
const info: ImpersonationInfo = {
|
||||
targetUserId,
|
||||
targetName: `${data.user?.firstName ?? ''} ${data.user?.lastName ?? ''}`.trim(),
|
||||
};
|
||||
localStorage.setItem(IMP_KEY, JSON.stringify(info));
|
||||
// Swap in the impersonation tokens.
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
return info;
|
||||
}
|
||||
|
||||
/** End impersonation, restoring the admin session. Caller should hard-reload. */
|
||||
export async function stopImpersonation(): Promise<string | null> {
|
||||
const info = getImpersonation();
|
||||
const adminAccess = localStorage.getItem('adminAccessToken');
|
||||
const adminRefresh = localStorage.getItem('adminRefreshToken');
|
||||
|
||||
// Restore the admin tokens first so the stop call is authorized as the admin.
|
||||
if (adminAccess) localStorage.setItem('accessToken', adminAccess);
|
||||
if (adminRefresh) localStorage.setItem('refreshToken', adminRefresh);
|
||||
localStorage.removeItem('adminAccessToken');
|
||||
localStorage.removeItem('adminRefreshToken');
|
||||
localStorage.removeItem(IMP_KEY);
|
||||
|
||||
if (info?.targetUserId) {
|
||||
try {
|
||||
await api.post(`/admin/users/${info.targetUserId}/impersonation/stop`);
|
||||
} catch {
|
||||
// Best-effort audit; never block returning to the admin session.
|
||||
}
|
||||
}
|
||||
return info?.targetUserId ?? null;
|
||||
}
|
||||
|
||||
/** Clear any impersonation artefacts (used on full logout). */
|
||||
export function clearImpersonation(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('adminAccessToken');
|
||||
localStorage.removeItem('adminRefreshToken');
|
||||
localStorage.removeItem(IMP_KEY);
|
||||
}
|
||||
Reference in New Issue
Block a user