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