feat(impersonation): implement user impersonation functionality and UI
This commit is contained in:
parent
2a0668fc03
commit
dcc58327e3
9 changed files with 290 additions and 8 deletions
15
src/app/(frontend)/impersonate/page.tsx
Normal file
15
src/app/(frontend)/impersonate/page.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -20,6 +20,9 @@ import { CommandPaletteProvider } from "@/components/command-palette/CommandPale
|
|||
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
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)/).
|
||||
// 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 payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
|
||||
|
||||
let xpLevel: ResolvedLevel = {
|
||||
levelName: "Recruit",
|
||||
|
|
@ -94,7 +98,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
|
||||
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
||||
|
||||
currentRank = (profileRes.docs[0]?.rank as Rank).abbreviation;
|
||||
currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? "";
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -119,10 +123,12 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
hasIntelligence={isIntelligence}
|
||||
hasLogistics={isLogistics}
|
||||
showCallsign={user.preferences?.display?.showCallsign ?? false}
|
||||
canImpersonate={await isSuperuser(payload, user)}
|
||||
className="border-r"
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
{impersonating && <ImpersonationBanner />}
|
||||
{children}
|
||||
</SidebarInset>
|
||||
<CommandPalette
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export default async function HomePage() {
|
|||
<div className="p-5 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">Here's your operations dashboard.</p>
|
||||
</div>
|
||||
|
|
|
|||
101
src/app/api/impersonation/start/route.ts
Normal file
101
src/app/api/impersonation/start/route.ts
Normal 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;
|
||||
}
|
||||
24
src/app/api/impersonation/stop/route.ts
Normal file
24
src/app/api/impersonation/stop/route.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -147,6 +147,7 @@ export function AppSidebar({
|
|||
hasIntelligence = true,
|
||||
hasLogistics = true,
|
||||
showCallsign = false,
|
||||
canImpersonate = false,
|
||||
...props
|
||||
}: {
|
||||
user: {
|
||||
|
|
@ -159,6 +160,7 @@ export function AppSidebar({
|
|||
hasIntelligence?: boolean;
|
||||
hasLogistics?: boolean;
|
||||
showCallsign?: boolean;
|
||||
canImpersonate?: boolean;
|
||||
} & React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar variant="inset" {...props}>
|
||||
|
|
@ -212,15 +214,14 @@ export function AppSidebar({
|
|||
<hr />
|
||||
<NavSecondary
|
||||
items={
|
||||
!adminUrl
|
||||
!adminUrl && !canImpersonate
|
||||
? data.navSecondary
|
||||
: [
|
||||
...data.navSecondary,
|
||||
{
|
||||
title: "Admin",
|
||||
url: adminUrl ?? "#",
|
||||
icon: Shield,
|
||||
},
|
||||
...(adminUrl ? [{ title: "Admin", url: adminUrl, icon: Shield }] : []),
|
||||
...(canImpersonate
|
||||
? [{ title: "Impersonate", url: "/impersonate", icon: UserCog }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
className="mt-auto"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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
46
src/lib/impersonation.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue