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 && (
|
||||
|
||||
Reference in New Issue
Block a user