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
@@ -6,6 +6,8 @@ import { useParams } from 'next/navigation';
import { useLocalizedRouter } from '@/i18n/navigation';
import { useT, useLocale } from '@/i18n/I18nProvider';
import api from '@/lib/api';
import { notify } from '@/lib/notify';
import { startImpersonation } from '@/lib/impersonation';
import type {
AdminUserOverview,
AdminUserApplication,
@@ -13,6 +15,7 @@ import type {
AdminUserTicket,
AdminActivityEvent,
AdminDeploymentLogs,
AdminAuditEntry,
} from '@/types';
import {
ArrowLeft,
@@ -27,9 +30,11 @@ import {
ChevronUp,
Layers,
CreditCard,
UserCog,
ShieldCheck,
} from 'lucide-react';
type TabKey = 'overview' | 'applications' | 'activity' | 'errors' | 'tickets';
type TabKey = 'overview' | 'applications' | 'activity' | 'errors' | 'tickets' | 'audit';
type Det = ReturnType<typeof useT>['dashboard']['users']['detail'];
const lifecycleBadge: Record<string, string> = {
@@ -78,6 +83,7 @@ export default function AdminUserDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useLocalizedRouter();
const [tab, setTab] = useState<TabKey>('overview');
const [impersonating, setImpersonating] = useState(false);
const money = (n: number | string) =>
`${Number(n || 0).toLocaleString('en-US')} ${t.common.currencyShort}`;
@@ -111,6 +117,24 @@ export default function AdminUserDetailPage() {
enabled: tab === 'tickets',
});
const audit = useQuery<AdminAuditEntry[]>({
queryKey: ['admin-user', id, 'audit'],
queryFn: () => api.get(`/admin/users/${id}/audit`).then((r) => r.data),
enabled: tab === 'audit',
});
const onLoginAsUser = async () => {
setImpersonating(true);
try {
await startImpersonation(id);
// Hard reload into the impersonated session.
window.location.href = `/${locale}/dashboard`;
} catch (err) {
notify.error(err, det.impersonateFailed);
setImpersonating(false);
}
};
if (isLoading) {
return (
<div className="text-center py-16">
@@ -130,8 +154,11 @@ export default function AdminUserDetailPage() {
{ key: 'activity', label: det.tabActivity, icon: <Layers className="w-4 h-4" /> },
{ key: 'errors', label: det.tabErrors, icon: <AlertTriangle className="w-4 h-4" /> },
{ key: 'tickets', label: det.tabTickets, icon: <TicketIcon className="w-4 h-4" />, badge: overview.counts.ticketsOpen },
{ key: 'audit', label: det.tabAudit, icon: <ShieldCheck className="w-4 h-4" /> },
];
const canImpersonate = p.role !== 'admin' && p.isActive;
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
@@ -142,24 +169,38 @@ export default function AdminUserDetailPage() {
>
<ArrowLeft className="w-4 h-4 rtl:rotate-180" /> {det.back}
</button>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold text-gray-900">
{p.firstName} {p.lastName}
</h1>
<span
className={`badge ${
p.role === 'admin' ? 'badge-purple' : p.role === 'user' ? 'badge-gray' : 'badge-blue'
}`}
>
{lookup(roleLabels, p.role)}
</span>
<span className={`badge ${p.isActive ? 'badge-green' : 'badge-red'}`}>
{p.isActive ? t.dashboard.users.active : t.dashboard.users.inactive}
</span>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold text-gray-900">
{p.firstName} {p.lastName}
</h1>
<span
className={`badge ${
p.role === 'admin' ? 'badge-purple' : p.role === 'user' ? 'badge-gray' : 'badge-blue'
}`}
>
{lookup(roleLabels, p.role)}
</span>
<span className={`badge ${p.isActive ? 'badge-green' : 'badge-red'}`}>
{p.isActive ? t.dashboard.users.active : t.dashboard.users.inactive}
</span>
</div>
<p className="text-sm text-gray-500 mt-1" dir="ltr">
{p.phone || p.email || '—'}
</p>
</div>
{canImpersonate && (
<button
onClick={onLoginAsUser}
disabled={impersonating}
className="btn-primary inline-flex items-center gap-2 disabled:opacity-60"
>
<UserCog className="w-4 h-4" />
{impersonating ? det.loading : det.loginAsUser}
</button>
)}
</div>
<p className="text-sm text-gray-500 mt-1" dir="ltr">
{p.phone || p.email || '—'}
</p>
</div>
{/* Tabs */}
@@ -367,6 +408,46 @@ export default function AdminUserDetailPage() {
}
</TabState>
)}
{/* ── Admin access log (impersonation audit) ── */}
{tab === 'audit' && (
<TabState query={audit}>
{(list) =>
list.length === 0 ? (
<div className="card text-center py-10 text-sm text-gray-500">{det.noAudit}</div>
) : (
<div className="table-wrapper">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50/80">
<tr>
<Th>{det.auditAction}</Th>
<Th>{det.auditAdmin}</Th>
<Th>{det.auditIp}</Th>
<Th>{det.auditTime}</Th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{list.map((a) => (
<tr key={a.id}>
<td className="px-4 py-3 text-sm">
<span className={`badge ${a.action === 'impersonation_start' ? 'badge-blue' : 'badge-gray'}`}>
{lookup(det.auditActions as Record<string, string>, a.action)}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-700">{a.actorName || a.actorUserId}</td>
<td className="px-4 py-3 text-sm text-gray-500" dir="ltr">{a.ip || '—'}</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(a.createdAt).toLocaleString(locale)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
</TabState>
)}
</div>
);
}
@@ -10,6 +10,7 @@ import { useT } from '@/i18n/I18nProvider';
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import type { Dictionary } from '@/i18n/dictionaries/fa';
import { DeploymentProgressManager } from '@/components/deployment-progress-manager';
import { ImpersonationBanner } from '@/components/impersonation-banner';
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
import {
LayoutDashboard,
@@ -222,6 +223,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<div className="min-h-screen bg-gray-50">
<ImpersonationBanner />
<DeploymentProgressManager />
{/* Mobile overlay */}
{sidebarOpen && (
@@ -0,0 +1,58 @@
'use client';
import { useEffect, useState } from 'react';
import { useT, useLocale } from '@/i18n/I18nProvider';
import {
getImpersonation,
stopImpersonation,
type ImpersonationInfo,
} from '@/lib/impersonation';
import { UserCog, LogOut } from 'lucide-react';
/**
* Persistent banner shown while an admin is impersonating a user. Reads the
* impersonation marker from localStorage (only changes across full reloads).
*/
export function ImpersonationBanner() {
const t = useT();
const locale = useLocale();
const det = t.dashboard.users.detail;
const [info, setInfo] = useState<ImpersonationInfo | null>(null);
const [exiting, setExiting] = useState(false);
useEffect(() => {
setInfo(getImpersonation());
}, []);
if (!info) return null;
const exit = async () => {
setExiting(true);
const targetId = await stopImpersonation();
// Hard reload back into the admin session, landing on the user's detail page.
window.location.href = targetId
? `/${locale}/dashboard/admin/users/${targetId}`
: `/${locale}/dashboard/admin/users`;
};
return (
<div className="bg-amber-500 text-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2 flex items-center justify-between gap-3">
<span className="flex items-center gap-2 text-sm font-medium min-w-0">
<UserCog className="w-4 h-4 shrink-0" />
<span className="truncate">
{det.impersonatingBanner.replace('{name}', info.targetName || '—')}
</span>
</span>
<button
onClick={exit}
disabled={exiting}
className="shrink-0 inline-flex items-center gap-1.5 bg-white/20 hover:bg-white/30 disabled:opacity-60 rounded-lg px-3 py-1 text-sm font-semibold transition-colors"
>
<LogOut className="w-3.5 h-3.5" />
{exiting ? det.loading : det.exitImpersonation}
</button>
</div>
</div>
);
}
+11
View File
@@ -869,6 +869,17 @@ const en: Dictionary = {
ticketDepts: { technical: 'Technical', sales: 'Sales' },
ticketPriorities: { low: 'Low', medium: 'Medium', high: 'High' },
senderRoles: { user: 'User', admin: 'Admin', technical: 'Technical support', sales: 'Sales' },
loginAsUser: 'Login as user',
impersonateFailed: 'Failed to start impersonation',
impersonatingBanner: 'You are viewing the panel as "{name}"',
exitImpersonation: 'Exit user mode',
tabAudit: 'Admin access log',
auditAction: 'Event',
auditAdmin: 'Admin',
auditTime: 'Time',
auditIp: 'IP',
noAudit: 'No admin access recorded.',
auditActions: { impersonation_start: 'Logged in as user', impersonation_stop: 'Exited user mode' },
},
},
pools: {
+11
View File
@@ -868,6 +868,17 @@ const fa = {
ticketDepts: { technical: 'فنی', sales: 'فروش' },
ticketPriorities: { low: 'کم', medium: 'متوسط', high: 'زیاد' },
senderRoles: { user: 'کاربر', admin: 'مدیر', technical: 'پشتیبان فنی', sales: 'کارشناس فروش' },
loginAsUser: 'ورود به‌عنوان کاربر',
impersonateFailed: 'ورود به‌عنوان کاربر ناموفق بود',
impersonatingBanner: 'شما در حال مشاهدهٔ پنل به‌عنوان «{name}» هستید',
exitImpersonation: 'خروج از حالت کاربر',
tabAudit: 'گزارش ورود ادمین',
auditAction: 'رویداد',
auditAdmin: 'ادمین',
auditTime: 'زمان',
auditIp: 'IP',
noAudit: 'هیچ ورود ادمینی ثبت نشده.',
auditActions: { impersonation_start: 'ورود به‌عنوان کاربر', impersonation_stop: 'خروج از حالت کاربر' },
},
},
pools: {
+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 });
},
+16
View File
@@ -568,6 +568,22 @@ export interface AdminActivityEvent {
meta: Record<string, any>;
}
export interface AdminAuditEntry {
id: string;
action: 'impersonation_start' | 'impersonation_stop' | string;
actorUserId: string;
actorName?: string | null;
ip?: string | null;
createdAt: string;
}
export interface ImpersonateResponse {
user: User;
accessToken: string;
refreshToken: string;
impersonation: { by: string; at: string };
}
export interface ClusterNode {
name: string;
status: string;