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 { 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
|
||||||
|
|
|
||||||
|
|
@ -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's your operations dashboard.</p>
|
<p className="text-sm text-muted-foreground">Here's your operations dashboard.</p>
|
||||||
</div>
|
</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,
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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