diff --git a/src/app/(app)/[account_id]/IndividualProfilePage.tsx b/src/app/(app)/[account_id]/IndividualProfilePage.tsx
index 7aa92d76..2b875874 100644
--- a/src/app/(app)/[account_id]/IndividualProfilePage.tsx
+++ b/src/app/(app)/[account_id]/IndividualProfilePage.tsx
@@ -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 {
@@ -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
+ }
/>
);
}
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index 521097be..155dc994 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -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;
@@ -28,6 +29,9 @@ export default function AppLayout({ children }: AppLayoutProps) {
+
+
+
{children}
diff --git a/src/app/(app)/logout/route.tsx b/src/app/(app)/logout/route.tsx
index fa4f8de5..9cd638e0 100644
--- a/src/app/(app)/logout/route.tsx
+++ b/src/app/(app)/logout/route.tsx
@@ -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;
@@ -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;
}
diff --git a/src/components/features/admin/ImpersonationBanner.tsx b/src/components/features/admin/ImpersonationBanner.tsx
new file mode 100644
index 00000000..997d9aa4
--- /dev/null
+++ b/src/components/features/admin/ImpersonationBanner.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ You are viewing the app as {session.account?.name}
+ . Actions you take are performed as this user.
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/features/admin/ViewAsButton.tsx b/src/components/features/admin/ViewAsButton.tsx
new file mode 100644
index 00000000..488fe7f0
--- /dev/null
+++ b/src/components/features/admin/ViewAsButton.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/features/profiles/IndividualProfile.tsx b/src/components/features/profiles/IndividualProfile.tsx
index 78ac699d..dafaa516 100644
--- a/src/components/features/profiles/IndividualProfile.tsx
+++ b/src/components/features/profiles/IndividualProfile.tsx
@@ -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 {
@@ -27,6 +28,7 @@ interface IndividualProfileProps {
organizations: OrganizationalAccount[];
showWelcome?: boolean;
canEdit: boolean;
+ canImpersonate?: boolean;
}
export function IndividualProfile({
@@ -37,6 +39,7 @@ export function IndividualProfile({
organizations,
showWelcome = false,
canEdit,
+ canImpersonate = false,
}: IndividualProfileProps) {
const primaryEmail = account.emails?.find((email) => email.is_primary);
return (
@@ -61,9 +64,14 @@ export function IndividualProfile({
)}
- {canEdit && (
-
- )}
+
+ {canImpersonate && (
+
+ )}
+ {canEdit && (
+
+ )}
+
diff --git a/src/lib/actions/admin.ts b/src/lib/actions/admin.ts
index 1c5fe6ff..219443cb 100644
--- a/src/lib/actions/admin.ts
+++ b/src/lib/actions/admin.ts
@@ -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"),
@@ -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 {
+ 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 {
+ await clearImpersonationTarget();
+ redirect("/");
+}
diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts
index c0b2ab88..10481a69 100644
--- a/src/lib/api/utils.ts
+++ b/src/lib/api/utils.ts
@@ -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.
@@ -117,13 +118,14 @@ export async function getPageSession(): Promise {
),
);
- // 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,
- };
+ });
}
/**
diff --git a/src/lib/services/impersonation.test.ts b/src/lib/services/impersonation.test.ts
new file mode 100644
index 00000000..fec945a1
--- /dev/null
+++ b/src/lib/services/impersonation.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/services/impersonation.ts b/src/lib/services/impersonation.ts
new file mode 100644
index 00000000..599d8054
--- /dev/null
+++ b/src/lib/services/impersonation.ts
@@ -0,0 +1,98 @@
+import "server-only";
+
+import { cookies } from "next/headers";
+import { Actions, type UserSession } from "@/types";
+import {
+ accountsTable,
+ membershipsTable,
+ isIndividualAccount,
+} from "@/lib/clients/database";
+import { isAdmin, isAuthorized } from "@/lib/api/authz";
+import { encryptJson, decryptJson } from "@/lib/services/encrypted-cookie";
+import { LOGGER } from "@/lib/logging";
+
+/** Encrypted HTTP-only cookie naming the account an admin is viewing as. */
+export const IMPERSONATION_COOKIE_NAME = "sc_impersonate";
+
+interface ImpersonationCookie {
+ account_id: string;
+}
+
+/**
+ * Admin-only. Set/clear from a server action (action phase); a Server
+ * Component render cannot write cookies. Not gated here — callers verify the
+ * real session is an admin first.
+ */
+export async function setImpersonationTarget(account_id: string): Promise {
+ const jar = await cookies();
+ jar.set(
+ IMPERSONATION_COOKIE_NAME,
+ await encryptJson({ account_id } satisfies ImpersonationCookie),
+ { httpOnly: true, secure: true, sameSite: "lax", path: "/" },
+ );
+}
+
+export async function clearImpersonationTarget(): Promise {
+ const jar = await cookies();
+ jar.delete(IMPERSONATION_COOKIE_NAME);
+}
+
+/**
+ * If the real session is an admin and an impersonation cookie is present,
+ * returns a session that fully assumes the target individual account —
+ * `identity_id`, `account`, and `memberships` all swapped, `impersonator` set
+ * so the UI can render a banner. Otherwise returns `realSession` unchanged.
+ *
+ * The gate is `isAdmin(realSession)`: a forged cookie from a non-admin is
+ * inert, so the encryption is defense-in-depth, not the security boundary.
+ * Only individual accounts are assumable — they alone have an Ory identity for
+ * the data proxy to mint credentials against.
+ */
+export async function applyImpersonation(
+ realSession: UserSession | null,
+): Promise {
+ if (!realSession || !isAdmin(realSession)) return realSession;
+
+ const jar = await cookies();
+ const token = jar.get(IMPERSONATION_COOKIE_NAME)?.value;
+ if (!token) return realSession;
+
+ const decoded = await decryptJson(token);
+ if (!decoded?.account_id) return realSession;
+
+ const target = await accountsTable.fetchById(decoded.account_id);
+ if (!target || target.disabled || !isIndividualAccount(target)) {
+ return realSession;
+ }
+
+ const principal: UserSession = {
+ orySession: realSession.orySession,
+ account: target,
+ identity_id: target.identity_id,
+ };
+ const memberships = (
+ await membershipsTable.listByUser(target.account_id)
+ ).filter((membership) =>
+ isAuthorized(principal, membership, Actions.GetMembership),
+ );
+
+ LOGGER.info("Admin viewing app as another user", {
+ operation: "applyImpersonation",
+ context: "impersonation",
+ metadata: {
+ admin: realSession.account?.account_id,
+ target: target.account_id,
+ },
+ });
+
+ return {
+ identity_id: target.identity_id,
+ account: target,
+ memberships,
+ orySession: realSession.orySession,
+ impersonator: {
+ account_id: realSession.account!.account_id,
+ name: realSession.account!.name,
+ },
+ };
+}
diff --git a/src/types/session.ts b/src/types/session.ts
index 5606db06..7a70dbd4 100644
--- a/src/types/session.ts
+++ b/src/types/session.ts
@@ -18,4 +18,11 @@ export interface UserSession {
account?: Account;
memberships?: Membership[];
orySession?: Session;
+ /**
+ * Present only when an admin is viewing the app as another user. Holds the
+ * real admin's identity so the UI can surface a banner; every other field on
+ * this session belongs to the impersonated target. See
+ * `src/lib/services/impersonation.ts`.
+ */
+ impersonator?: { account_id: string; name: string };
}