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:
keyhan
2026-06-18 00:49:02 +03:30
parent 7958d2fa72
commit fd38f5659f
17 changed files with 532 additions and 27 deletions
+81
View File
@@ -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);
}
+2
View File
@@ -2,6 +2,7 @@
import { create } from 'zustand';
import api from '@/lib/api';
import { clearImpersonation } from '@/lib/impersonation';
import type { User, AuthResponse } from '@/types';
export interface RegisterData {
@@ -80,6 +81,7 @@ export const useAuthStore = create<AuthState>((set) => ({
logout: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
clearImpersonation();
set({ user: null, isAuthenticated: false });
},