fd38f5659f
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>
59 lines
1.9 KiB
TypeScript
59 lines
1.9 KiB
TypeScript
'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>
|
|
);
|
|
}
|