diff --git a/src/app/(frontend)/impersonate/page.tsx b/src/app/(frontend)/impersonate/page.tsx new file mode 100644 index 0000000..3732b12 --- /dev/null +++ b/src/app/(frontend)/impersonate/page.tsx @@ -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
[0]["users"]} />
; +} diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 20f6ad8..ffa8db2 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -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" /> + {impersonating && } {children}

- Welcome back, {(profile?.rank as Rank).name}. {/*{profileUser?.displayName ?? user.username}*/} + Welcome back, {rankData.name}. {/*{profileUser?.displayName ?? user.username}*/}

Here's your operations dashboard.

diff --git a/src/app/api/impersonation/start/route.ts b/src/app/api/impersonation/start/route.ts new file mode 100644 index 0000000..195c39e --- /dev/null +++ b/src/app/api/impersonation/start/route.ts @@ -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; +} diff --git a/src/app/api/impersonation/stop/route.ts b/src/app/api/impersonation/stop/route.ts new file mode 100644 index 0000000..535a9b9 --- /dev/null +++ b/src/app/api/impersonation/stop/route.ts @@ -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; +} diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx index d3bd2aa..2860059 100644 --- a/src/components/frontend/blocks/AppSidebar.tsx +++ b/src/components/frontend/blocks/AppSidebar.tsx @@ -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) { return ( @@ -212,15 +214,14 @@ export function AppSidebar({
+