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 && (