1
0
Fork 0

feat(impersonation): implement user impersonation functionality and UI

This commit is contained in:
Jason Fraley 2026-08-21 01:41:14 -04:00
parent 2a0668fc03
commit dcc58327e3
9 changed files with 290 additions and 8 deletions

View file

@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import config from "@payload-config";
import { ImpersonationControl } from "@/components/frontend/impersonation/ImpersonationControl";
import { findImpersonationUsers } from "@/lib/impersonation";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { headers } from "next/headers";
export default async function ImpersonatePage() {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false });
if (!user || !(await isSuperuser(payload, user))) redirect("/");
const users = await findImpersonationUsers(payload);
return <main className="flex-1 space-y-6 p-6 md:p-8"><ImpersonationControl users={users.docs as Parameters<typeof ImpersonationControl>[0]["users"]} /></main>;
}

View file

@ -20,6 +20,9 @@ import { CommandPaletteProvider } from "@/components/command-palette/CommandPale
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider"; import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
import { isSuperuser } from "@/utils/access-control/hasPermission"; import { isSuperuser } from "@/utils/access-control/hasPermission";
import { Rank } from "@/payload-types"; import { Rank } from "@/payload-types";
import { cookies } from "next/headers";
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
// Skip static prerendering of this layout (and all pages under (frontend)/). // Skip static prerendering of this layout (and all pages under (frontend)/).
// The layout calls `getPayload()` at render time to resolve the current user, // The layout calls `getPayload()` at render time to resolve the current user,
@ -48,6 +51,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user } = await payload.auth({ headers, canSetHeaders: false });
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
let xpLevel: ResolvedLevel = { let xpLevel: ResolvedLevel = {
levelName: "Recruit", levelName: "Recruit",
@ -94,7 +98,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const userXp = profileRes.docs[0]?.progression?.xp ?? 0; const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
xpLevel = resolveLevel(userXp, experienceRes.docs); xpLevel = resolveLevel(userXp, experienceRes.docs);
currentRank = (profileRes.docs[0]?.rank as Rank).abbreviation; currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? "";
} }
return ( return (
@ -119,10 +123,12 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
hasIntelligence={isIntelligence} hasIntelligence={isIntelligence}
hasLogistics={isLogistics} hasLogistics={isLogistics}
showCallsign={user.preferences?.display?.showCallsign ?? false} showCallsign={user.preferences?.display?.showCallsign ?? false}
canImpersonate={await isSuperuser(payload, user)}
className="border-r" className="border-r"
/> />
<SidebarInset> <SidebarInset>
<SiteHeader /> <SiteHeader />
{impersonating && <ImpersonationBanner />}
{children} {children}
</SidebarInset> </SidebarInset>
<CommandPalette <CommandPalette

View file

@ -205,7 +205,7 @@ export default async function HomePage() {
<div className="p-5 flex flex-col gap-6"> <div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">
Welcome back, {(profile?.rank as Rank).name}. {/*{profileUser?.displayName ?? user.username}*/} Welcome back, {rankData.name}. {/*{profileUser?.displayName ?? user.username}*/}
</h1> </h1>
<p className="text-sm text-muted-foreground">Here&apos;s your operations dashboard.</p> <p className="text-sm text-muted-foreground">Here&apos;s your operations dashboard.</p>
</div> </div>

View file

@ -0,0 +1,101 @@
import { getFieldsToSign, jwtSign } from "payload";
import { getPayload } from "payload";
import config from "@payload-config";
import { NextResponse } from "next/server";
import { headers as nextHeaders } from "next/headers";
import { randomUUID } from "node:crypto";
import {
IMPERSONATION_ACTIVE_COOKIE,
IMPERSONATION_COOKIE,
getImpersonationCookieOptions,
isImpersonationTarget,
} from "@/lib/impersonation";
import { isSuperuser } from "@/utils/access-control/hasPermission";
export async function POST(request: Request) {
const payload = await getPayload({ config });
const headers = await nextHeaders();
const { user: operator } = await payload.auth({ headers, canSetHeaders: false });
if (!operator || !(await isSuperuser(payload, operator))) {
return NextResponse.json({ error: "Only developers may impersonate users." }, { status: 403 });
}
const currentOriginalToken = request.headers.get("cookie")?.match(
/(?:^|;\s*)ptf-impersonation-original=([^;]+)/,
)?.[1];
if (currentOriginalToken) {
return NextResponse.json({ error: "An impersonation session is already active." }, { status: 409 });
}
const body = (await request.json().catch(() => null)) as { userId?: number | string } | null;
const userId = body?.userId;
if (userId === undefined || userId === "") {
return NextResponse.json({ error: "A target user is required." }, { status: 400 });
}
const target = await payload.findByID({
collection: "users",
id: userId,
depth: 0,
overrideAccess: true,
});
if (!target || !isImpersonationTarget(operator, target)) {
return NextResponse.json({ error: "That impersonation target is not valid." }, { status: 400 });
}
const collection = payload.collections.users.config;
const sid = randomUUID();
const now = new Date();
const expiresAt = new Date(now.getTime() + collection.auth.tokenExpiration * 1000);
// Payload validates session-backed JWTs by looking up the sid on the user.
// Creating a token without this persisted session makes auth() return null.
const sessions = (target.sessions ?? []).filter(
(session) => new Date(session.expiresAt).getTime() > now.getTime(),
);
sessions.push({
id: sid,
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
});
await payload.update({
collection: "users",
id: target.id,
data: { sessions },
overrideAccess: true,
});
const { token } = await jwtSign({
fieldsToSign: getFieldsToSign({
collectionConfig: collection,
email: target.email ?? "",
sid,
user: target,
}),
secret: payload.secret,
tokenExpiration: collection.auth.tokenExpiration,
});
const originalToken = request.headers.get("cookie")?.match(/(?:^|;\s*)payload-token=([^;]+)/)?.[1];
if (!originalToken) {
return NextResponse.json({ error: "The current login session could not be preserved." }, { status: 401 });
}
const response = NextResponse.json({
user: {
id: target.id,
username: target.username,
displayName: target.displayName,
},
});
const maxAge = collection.auth.tokenExpiration;
response.cookies.set(
`${payload.config.cookiePrefix}-token`,
token,
getImpersonationCookieOptions(maxAge),
);
response.cookies.set(IMPERSONATION_COOKIE, originalToken, getImpersonationCookieOptions(maxAge));
response.cookies.set(IMPERSONATION_ACTIVE_COOKIE, "1", getImpersonationCookieOptions(maxAge));
return response;
}

View file

@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getPayload } from "payload";
import config from "@payload-config";
import { IMPERSONATION_ACTIVE_COOKIE, IMPERSONATION_COOKIE, getImpersonationCookieOptions } from "@/lib/impersonation";
export async function POST() {
const cookieStore = await cookies();
const originalToken = cookieStore.get(IMPERSONATION_COOKIE)?.value;
if (!originalToken) {
return NextResponse.json({ error: "No impersonation session is active." }, { status: 400 });
}
const payload = await getPayload({ config });
const response = NextResponse.json({ ok: true });
response.cookies.set(
`${payload.config.cookiePrefix}-token`,
originalToken,
getImpersonationCookieOptions(payload.collections.users.config.auth.tokenExpiration),
);
response.cookies.set(IMPERSONATION_COOKIE, "", { ...getImpersonationCookieOptions(0), expires: new Date(0) });
response.cookies.set(IMPERSONATION_ACTIVE_COOKIE, "", { ...getImpersonationCookieOptions(0), expires: new Date(0) });
return response;
}

View file

@ -147,6 +147,7 @@ export function AppSidebar({
hasIntelligence = true, hasIntelligence = true,
hasLogistics = true, hasLogistics = true,
showCallsign = false, showCallsign = false,
canImpersonate = false,
...props ...props
}: { }: {
user: { user: {
@ -159,6 +160,7 @@ export function AppSidebar({
hasIntelligence?: boolean; hasIntelligence?: boolean;
hasLogistics?: boolean; hasLogistics?: boolean;
showCallsign?: boolean; showCallsign?: boolean;
canImpersonate?: boolean;
} & React.ComponentProps<typeof Sidebar>) { } & React.ComponentProps<typeof Sidebar>) {
return ( return (
<Sidebar variant="inset" {...props}> <Sidebar variant="inset" {...props}>
@ -212,15 +214,14 @@ export function AppSidebar({
<hr /> <hr />
<NavSecondary <NavSecondary
items={ items={
!adminUrl !adminUrl && !canImpersonate
? data.navSecondary ? data.navSecondary
: [ : [
...data.navSecondary, ...data.navSecondary,
{ ...(adminUrl ? [{ title: "Admin", url: adminUrl, icon: Shield }] : []),
title: "Admin", ...(canImpersonate
url: adminUrl ?? "#", ? [{ title: "Impersonate", url: "/impersonate", icon: UserCog }]
icon: Shield, : []),
},
] ]
} }
className="mt-auto" className="mt-auto"

View file

@ -0,0 +1,34 @@
"use client";
import { Eye, LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export function ImpersonationBanner() {
const router = useRouter();
const [stopping, setStopping] = useState(false);
async function stop() {
setStopping(true);
try {
const response = await fetch("/api/impersonation/stop", { method: "POST" });
if (!response.ok) throw new Error("Unable to end impersonation");
router.push("/");
router.refresh();
} finally {
setStopping(false);
}
}
return (
<div className="flex items-center justify-center gap-3 border-b border-amber-400/30 bg-amber-500/10 px-4 py-2 text-sm text-amber-100">
<Eye className="size-4 shrink-0" aria-hidden="true" />
<span>You are impersonating another user.</span>
<Button type="button" variant="outline" size="sm" onClick={stop} disabled={stopping} className="h-7 border-amber-400/40 bg-transparent text-amber-100 hover:bg-amber-400/10 hover:text-amber-50">
<LogOut className="size-3.5" aria-hidden="true" />
{stopping ? "Returning..." : "Return to my account"}
</Button>
</div>
);
}

View file

@ -0,0 +1,55 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type Target = { id: number | string; username?: string | null; displayName?: string | null };
export function ImpersonationControl({ users }: { users: Target[] }) {
const router = useRouter();
const [userId, setUserId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function start() {
if (!userId) return;
setBusy(true);
setError(null);
try {
const response = await fetch("/api/impersonation/start", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ userId }),
});
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(body?.error ?? "Unable to start impersonation.");
router.push("/");
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Unable to start impersonation.");
} finally {
setBusy(false);
}
}
return (
<div className="max-w-xl rounded-xl border border-border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">Impersonate a user</h1>
<p className="mt-2 text-sm text-muted-foreground">All requests will use the selected user&apos;s identity and permissions until you return to your account.</p>
<div className="mt-6 flex flex-col gap-2">
<Label htmlFor="impersonation-user">User</Label>
<Select value={userId} onValueChange={setUserId}>
<SelectTrigger id="impersonation-user"><SelectValue placeholder="Select a user" /></SelectTrigger>
<SelectContent>
{users.map((user) => <SelectItem key={String(user.id)} value={String(user.id)}>{user.displayName || user.username || `User ${user.id}`} {user.username ? `(${user.username})` : ""}</SelectItem>)}
</SelectContent>
</Select>
</div>
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
<Button className="mt-6" onClick={start} disabled={!userId || busy}>{busy ? "Starting..." : "Start impersonation"}</Button>
</div>
);
}

46
src/lib/impersonation.ts Normal file
View file

@ -0,0 +1,46 @@
import type { Payload } from "payload";
export const IMPERSONATION_COOKIE = "ptf-impersonation-original";
export const IMPERSONATION_ACTIVE_COOKIE = "ptf-impersonation-active";
export type ImpersonationUser = {
id: number | string;
username?: string | null;
displayName?: string | null;
};
export function getImpersonationCookieOptions(maxAge: number) {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge,
};
}
export function userLabel(user: ImpersonationUser): string {
return user.displayName || user.username || `User ${user.id}`;
}
export function isImpersonationTarget(
operator: ImpersonationUser,
target: ImpersonationUser,
): boolean {
return String(operator.id) !== String(target.id);
}
export async function findImpersonationUsers(payload: Payload) {
return payload.find({
collection: "users",
depth: 0,
limit: 1000,
pagination: false,
sort: "username",
select: {
username: true,
displayName: true,
},
overrideAccess: true,
});
}