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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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 });
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user