Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/app/(app)/[account_id]/IndividualProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "@/lib/clients/database";
import { type IndividualAccount, Actions } from "@/types";
import { getPageSession } from "@/lib/api/utils";
import { isAuthorized } from "@/lib/api/authz";
import { isAdmin, isAuthorized } from "@/lib/api/authz";
import { IndividualProfile } from "@/components/features/profiles/IndividualProfile";

interface IndividualProfilePageProps {
Expand Down Expand Up @@ -51,6 +51,12 @@ export async function IndividualProfilePage({
organizations={organizations}
showWelcome={showWelcome}
canEdit={isAuthorized(session, account, Actions.PutAccountProfile)}
// Admins (never an already-impersonated session, whose account is the
// non-admin target) can view the app as this user, unless it's their own.
canImpersonate={
isAdmin(session) &&
session?.account?.account_id !== account.account_id
}
/>
);
}
4 changes: 4 additions & 0 deletions src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Box, Container, Flex } from "@radix-ui/themes";
import { Navigation, Footer } from "@/components";
import { VerificationBanner } from "@/components/features/auth/VerificationBanner";
import { StepUpGuard } from "@/components/features/auth/StepUpGuard";
import { ImpersonationBanner } from "@/components/features/admin/ImpersonationBanner";

interface AppLayoutProps {
children: React.ReactNode;
Expand All @@ -28,6 +29,9 @@ export default function AppLayout({ children }: AppLayoutProps) {
<Suspense fallback={null}>
<VerificationBanner />
</Suspense>
<Suspense fallback={null}>
<ImpersonationBanner />
</Suspense>
{children}
</Container>
</Box>
Expand Down
9 changes: 9 additions & 0 deletions src/app/(app)/logout/route.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CONFIG, LOGGER } from "@/lib";
import { NextRequest, NextResponse } from "next/server";
import { PROXY_CREDS_COOKIE_NAME } from "@/lib/services/proxy-credentials-shared";
import { IMPERSONATION_COOKIE_NAME } from "@/lib/services/impersonation";

export async function GET(request: NextRequest) {
const returnTo = new URL(request.url).origin;
Expand Down Expand Up @@ -46,5 +47,13 @@ export async function GET(request: NextRequest) {
secure: true,
sameSite: "lax",
});
// Also drop any active impersonation so the next session starts as itself.
res.cookies.set(IMPERSONATION_COOKIE_NAME, "", {
path: "/",
maxAge: 0,
httpOnly: true,
secure: true,
sameSite: "lax",
});
return res;
}
38 changes: 38 additions & 0 deletions src/components/features/admin/ImpersonationBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Box, Button, Callout, Flex } from "@radix-ui/themes";
import { EyeOpenIcon, ExitIcon } from "@radix-ui/react-icons";
import { getPageSession } from "@/lib/api/utils";
import { stopImpersonation } from "@/lib/actions/admin";

/**
* Always-on warning shown while an admin is viewing the app as another user.
* Renders nothing for a normal session. The session it reads is already the
* impersonated target (see `applyImpersonation`); `impersonator` carries the
* real admin so we can name who is really driving and offer an exit.
*/
export async function ImpersonationBanner() {
const session = await getPageSession();
if (!session?.impersonator) return null;

return (
<Box mb="4">
<Callout.Root color="amber" role="alert">
<Flex align="center" justify="between" gap="3" wrap="wrap">
<Flex align="center" gap="2">
<Callout.Icon>
<EyeOpenIcon />
</Callout.Icon>
<Callout.Text>
You are viewing the app as <strong>{session.account?.name}</strong>
. Actions you take are performed as this user.
</Callout.Text>
</Flex>
<form action={stopImpersonation}>
<Button type="submit" variant="solid" color="amber" size="1">
<ExitIcon /> Exit
</Button>
</form>
</Flex>
</Callout.Root>
</Box>
);
}
20 changes: 20 additions & 0 deletions src/components/features/admin/ViewAsButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Button } from "@radix-ui/themes";
import { EyeOpenIcon } from "@radix-ui/react-icons";
import { startImpersonation } from "@/lib/actions/admin";

/**
* Admin control (shown next to a user's profile Edit button) that starts
* viewing the app as that user. Rendering is gated by the caller — only an
* admin viewing someone else's profile sees it. The server action re-checks
* admin, so it is safe even if the button leaks into other renders.
*/
export function ViewAsButton({ targetAccountId }: { targetAccountId: string }) {
return (
<form action={startImpersonation}>
<input type="hidden" name="account_id" value={targetAccountId} />
<Button type="submit" variant="soft" size="2">
<EyeOpenIcon /> View as user
</Button>
</form>
);
}
14 changes: 11 additions & 3 deletions src/components/features/profiles/IndividualProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ProductsList } from "../products/ProductsList";
import { WebsiteLink } from "./WebsiteLink";
import { EmailVerificationStatus } from "./EmailVerificationStatus";
import { AvatarLinkCompact, EditButton } from "@/components/core";
import { ViewAsButton } from "@/components/features/admin/ViewAsButton";
import { WelcomeCallout } from "./WelcomeCallout";

interface IndividualProfileProps {
Expand All @@ -27,6 +28,7 @@ interface IndividualProfileProps {
organizations: OrganizationalAccount[];
showWelcome?: boolean;
canEdit: boolean;
canImpersonate?: boolean;
}

export function IndividualProfile({
Expand All @@ -37,6 +39,7 @@ export function IndividualProfile({
organizations,
showWelcome = false,
canEdit,
canImpersonate = false,
}: IndividualProfileProps) {
const primaryEmail = account.emails?.find((email) => email.is_primary);
return (
Expand All @@ -61,9 +64,14 @@ export function IndividualProfile({
)}
</Box>
</Flex>
{canEdit && (
<EditButton href={editAccountProfileUrl(account.account_id)} />
)}
<Flex gap="2" align="center">
{canImpersonate && (
<ViewAsButton targetAccountId={account.account_id} />
)}
{canEdit && (
<EditButton href={editAccountProfileUrl(account.account_id)} />
)}
</Flex>
</Flex>
</Box>

Expand Down
32 changes: 31 additions & 1 deletion src/lib/actions/admin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"use server";

import { z } from "zod";
import { redirect } from "next/navigation";
import { LOGGER } from "@/lib/logging";
import { isAdmin } from "../api/authz";
import { getOryIdentityIdByEmail, getPageSession } from "../api/utils";
import { accountsTable } from "../clients";
import { accountsTable, isIndividualAccount } from "../clients";
import { FormState } from "@/components/core/DynamicForm";
import { accountUrl } from "@/lib/urls";
import {
setImpersonationTarget,
clearImpersonationTarget,
} from "@/lib/services/impersonation";

const LookupSchema = z.object({
email: z.string().trim().email("Enter a valid email address"),
Expand Down Expand Up @@ -86,3 +91,28 @@ export async function lookupUserByEmail(
redirectTo: accountUrl(account.account_id),
};
}

/**
* Admin-only: start viewing the app as another (individual) user. Sets the
* impersonation cookie that `getPageSession` honors, then reloads onto the
* target's profile. `getPageSession()` here resolves the REAL admin (the
* cookie isn't set yet), so the `isAdmin` gate can't be bypassed by an
* already-impersonated session.
*/
export async function startImpersonation(formData: FormData): Promise<void> {
const session = await getPageSession();
if (!isAdmin(session)) return; // button is admin-only; ignore stray posts

const account_id = String(formData.get("account_id") ?? "");
const target = await accountsTable.fetchById(account_id);
if (!target || target.disabled || !isIndividualAccount(target)) return;

await setImpersonationTarget(account_id);
redirect(accountUrl(account_id));
}

/** Stop impersonating and return home. Safe for anyone — just clears a cookie. */
export async function stopImpersonation(): Promise<void> {
await clearImpersonationTarget();
redirect("/");
}
8 changes: 5 additions & 3 deletions src/lib/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import md5 from "md5";
import { authenticateWithOidcToken } from "./oidc";
import { CONFIG } from "@/lib/config";
import { LOGGER } from "@/lib/logging";
import { applyImpersonation } from "@/lib/services/impersonation";

/**
* Retrieves the current user session from the request context.
Expand Down Expand Up @@ -117,13 +118,14 @@ export async function getPageSession(): Promise<UserSession | null> {
),
);

// Return the user session
return {
// Return the user session — swapped to the target when an admin is viewing
// the app as another user (no-op for everyone else).
return applyImpersonation({
identity_id,
orySession,
account,
memberships: filteredMemberships,
};
});
}

/**
Expand Down
105 changes: 105 additions & 0 deletions src/lib/services/impersonation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* @jest-environment node
*/

const mockGet = jest.fn();
jest.mock("next/headers", () => ({
cookies: jest.fn().mockResolvedValue({
get: (...args: unknown[]) => mockGet(...args),
set: jest.fn(),
delete: jest.fn(),
}),
}));

const mockFetchById = jest.fn();
const mockListByUser = jest.fn();
jest.mock("@/lib/clients/database", () => ({
accountsTable: { fetchById: (...a: unknown[]) => mockFetchById(...a) },
membershipsTable: { listByUser: (...a: unknown[]) => mockListByUser(...a) },
isIndividualAccount: (acc: { type?: string }) => acc?.type === "individual",
}));

const mockIsAdmin = jest.fn();
jest.mock("@/lib/api/authz", () => ({
isAdmin: (...a: unknown[]) => mockIsAdmin(...a),
isAuthorized: () => true,
}));

import { applyImpersonation, IMPERSONATION_COOKIE_NAME } from "./impersonation";
import { encryptJson } from "./encrypted-cookie";
import type { UserSession } from "@/types";

const adminSession = (): UserSession => ({
identity_id: "admin-ory-id",
orySession: { id: "sess" } as never,
account: { account_id: "admin", name: "Admin", type: "individual" } as never,
memberships: [],
});

const target = {
account_id: "alice",
name: "Alice",
type: "individual",
identity_id: "alice-ory-id",
disabled: false,
};

async function cookieFor(account_id: string) {
return { value: await encryptJson({ account_id }) };
}

describe("applyImpersonation", () => {
beforeEach(() => {
mockGet.mockReset();
mockFetchById.mockReset();
mockListByUser.mockReset().mockResolvedValue([]);
mockIsAdmin.mockReset();
});

test("swaps identity, account, and sets impersonator when admin + valid cookie", async () => {
mockIsAdmin.mockReturnValue(true);
mockGet.mockReturnValue(await cookieFor("alice"));
mockFetchById.mockResolvedValue(target);

const result = await applyImpersonation(adminSession());

expect(mockGet).toHaveBeenCalledWith(IMPERSONATION_COOKIE_NAME);
expect(result?.identity_id).toBe("alice-ory-id"); // full-identity swap
expect(result?.account?.account_id).toBe("alice");
expect(result?.impersonator).toEqual({ account_id: "admin", name: "Admin" });
});

test("returns the real session unchanged for a non-admin (forged cookie is inert)", async () => {
mockIsAdmin.mockReturnValue(false);
mockGet.mockReturnValue(await cookieFor("alice"));

const real = { ...adminSession(), account: undefined };
const result = await applyImpersonation(real);

expect(result).toBe(real);
expect(mockGet).not.toHaveBeenCalled(); // short-circuits before reading cookie
expect(mockFetchById).not.toHaveBeenCalled();
});

test("returns the real session when admin but no cookie", async () => {
mockIsAdmin.mockReturnValue(true);
mockGet.mockReturnValue(undefined);

const real = adminSession();
expect(await applyImpersonation(real)).toBe(real);
});

test("ignores a cookie pointing at a non-individual (organization) account", async () => {
mockIsAdmin.mockReturnValue(true);
mockGet.mockReturnValue(await cookieFor("acme"));
mockFetchById.mockResolvedValue({
account_id: "acme",
name: "Acme",
type: "organization",
disabled: false,
});

const real = adminSession();
expect(await applyImpersonation(real)).toBe(real);
});
});
Loading
Loading