diff --git a/.server-changes/impersonation-consent-and-view-as-user.md b/.server-changes/impersonation-consent-and-view-as-user.md
new file mode 100644
index 00000000000..db78d9c375e
--- /dev/null
+++ b/.server-changes/impersonation-consent-and-view-as-user.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: improvement
+---
+
+Admins opening an impersonation link from outside the dashboard now get a confirmation page naming the organization and destination instead of being bounced back, and while impersonating they can switch to "View as user" to see the dashboard without any admin-only UI.
diff --git a/apps/webapp/app/components/admin/debugRun.tsx b/apps/webapp/app/components/admin/debugRun.tsx
index 705e3169a8b..049c5cd08c3 100644
--- a/apps/webapp/app/components/admin/debugRun.tsx
+++ b/apps/webapp/app/components/admin/debugRun.tsx
@@ -1,4 +1,3 @@
-import { useIsImpersonating } from "~/hooks/useOrganizations";
import { useHasAdminAccess } from "~/hooks/useUser";
import { Button } from "../primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
@@ -12,10 +11,11 @@ import * as Property from "~/components/primitives/PropertyTable";
import { ClipboardField } from "../primitives/ClipboardField";
export function AdminDebugRun({ friendlyId }: { friendlyId: string }) {
+ // `useHasAdminAccess` already folds in impersonation and the "view as user"
+ // toggle, so this one check is enough.
const hasAdminAccess = useHasAdminAccess();
- const isImpersonating = useIsImpersonating();
- if (!hasAdminAccess && !isImpersonating) {
+ if (!hasAdminAccess) {
return null;
}
diff --git a/apps/webapp/app/components/admin/debugTooltip.tsx b/apps/webapp/app/components/admin/debugTooltip.tsx
index 4157f898163..1729bfa740c 100644
--- a/apps/webapp/app/components/admin/debugTooltip.tsx
+++ b/apps/webapp/app/components/admin/debugTooltip.tsx
@@ -8,15 +8,16 @@ import {
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
-import { useIsImpersonating, useOptionalOrganization } from "~/hooks/useOrganizations";
+import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
import { useHasAdminAccess, useUser } from "~/hooks/useUser";
export function AdminDebugTooltip({ children }: { children?: React.ReactNode }) {
+ // `useHasAdminAccess` already folds in impersonation and the "view as user"
+ // toggle, so this one check is enough.
const hasAdminAccess = useHasAdminAccess();
- const isImpersonating = useIsImpersonating();
- if (!hasAdminAccess && !isImpersonating) {
+ if (!hasAdminAccess) {
return null;
}
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index 29eb1fc8520..625ee3d74e9 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -4,7 +4,14 @@ import {
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
-import { useFetcher, useNavigation, useRevalidator, useSubmit } from "@remix-run/react";
+import {
+ Form,
+ useFetcher,
+ useLocation,
+ useNavigation,
+ useRevalidator,
+ useSubmit,
+} from "@remix-run/react";
import { LayoutGroup, motion } from "framer-motion";
import {
type CSSProperties,
@@ -33,6 +40,8 @@ import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon";
import { DialIcon } from "~/assets/icons/DialIcon";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
+import { EyeClosedIcon } from "~/assets/icons/EyeClosedIcon";
+import { EyeOpenIcon } from "~/assets/icons/EyeOpenIcon";
import { FolderClosedIcon } from "~/assets/icons/FolderClosedIcon";
import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon";
import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon";
@@ -69,7 +78,7 @@ import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
-import { useHasAdminAccess } from "~/hooks/useUser";
+import { useHasAdminAccess, useIsViewingAsUser } from "~/hooks/useUser";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import {
useCurrentPlan,
@@ -785,7 +794,7 @@ export function SideMenu({
// user's saved order/hidden preferences are applied at render below.
const staticSections: SideMenuSectionConfig[] = [];
- if (user.admin || user.isImpersonating || featureFlags.hasAiAccess) {
+ if (isAdmin || featureFlags.hasAiAccess) {
staticSections.push({
id: "ai",
title: "AI",
@@ -813,12 +822,12 @@ export function SideMenu({
});
}
- if (user.admin || user.isImpersonating || featureFlags.hasQueryAccess) {
+ if (isAdmin || featureFlags.hasQueryAccess) {
staticSections.push({
id: "metrics",
title: "Observability",
items: [
- ...(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess
+ ...(isAdmin || featureFlags.hasLogsPageAccess
? [
{
id: "logs",
@@ -1832,24 +1841,29 @@ function AccountMenuItems({
return (
<>
- {isAdmin && (
+ {/* "Stop impersonating" and the view-as-user toggle key off raw impersonation, not `isAdmin`:
+ with "view as user" on, `isAdmin` is false and these are the only ways back out. */}
+ {(isImpersonating || isAdmin) && (
{isImpersonating ? (
-
- Stop impersonating
-
-
- }
- icon={UserCrossIcon}
- onClick={stopImpersonating}
- leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
- className={SIDE_MENU_POPOVER_ITEM_LABEL}
- />
+ <>
+
+ Stop impersonating
+
+
+ }
+ icon={UserCrossIcon}
+ onClick={stopImpersonating}
+ leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
+ className={SIDE_MENU_POPOVER_ITEM_LABEL}
+ />
+
+ >
) : (
+
+
+
+
+ );
+}
+
function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImpersonating: boolean }) {
const [isOpen, setIsOpen] = useState(false);
const navigation = useNavigation();
diff --git a/apps/webapp/app/hooks/useUser.ts b/apps/webapp/app/hooks/useUser.ts
index fd9938fdb9a..aa86ba63865 100644
--- a/apps/webapp/app/hooks/useUser.ts
+++ b/apps/webapp/app/hooks/useUser.ts
@@ -30,9 +30,23 @@ export function useUserChanged(callback: (user: User | undefined) => void) {
useChanged(useOptionalUser, callback);
}
+/**
+ * Whether the admin has switched to "view as user" for the current
+ * impersonation session. Display only — see `hasAdminDisplayAccess`.
+ */
+export function useIsViewingAsUser(matches?: UIMatch[]): boolean {
+ const routeMatch = useTypedMatchesData({
+ id: "root",
+ matches,
+ });
+
+ return routeMatch?.isViewingAsUser === true;
+}
+
export function useHasAdminAccess(matches?: UIMatch[]): boolean {
const user = useOptionalUser(matches);
const isImpersonating = useIsImpersonating(matches);
+ const isViewingAsUser = useIsViewingAsUser(matches);
- return Boolean(user?.admin) || isImpersonating;
+ return (Boolean(user?.admin) || isImpersonating) && !isViewingAsUser;
}
diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts
index 09811c99c2d..e93844dbaed 100644
--- a/apps/webapp/app/models/admin.server.ts
+++ b/apps/webapp/app/models/admin.server.ts
@@ -1,5 +1,5 @@
import { redirect } from "@remix-run/server-runtime";
-import { prisma } from "~/db.server";
+import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
import { logger } from "~/services/logger.server";
import type { SearchParams } from "~/routes/admin._index";
import {
@@ -11,6 +11,7 @@ import {
import { authenticator } from "~/services/auth.server";
import { requireUser } from "~/services/session.server";
import { extractClientIp } from "~/utils/extractClientIp.server";
+import { impersonationDestinationPath } from "~/utils/pathBuilder";
const pageSize = 20;
@@ -213,7 +214,8 @@ export async function redirectWithImpersonation(
request: Request,
userId: string,
path: string,
- currentUser?: { id: string; admin: boolean }
+ currentUser?: { id: string; admin: boolean },
+ prismaClient: PrismaClientOrTransaction = prisma
) {
const user = currentUser ?? (await requireUser(request));
if (!user.admin) {
@@ -224,7 +226,7 @@ export async function redirectWithImpersonation(
const ipAddress = extractClientIp(xff);
try {
- await prisma.impersonationAuditLog.create({
+ await prismaClient.impersonationAuditLog.create({
data: {
action: "START",
adminId: user.id,
@@ -247,6 +249,87 @@ export async function redirectWithImpersonation(
});
}
+type ImpersonationTarget =
+ | { success: true; userId: string; organizationName: string }
+ | { success: false; reason: "org-not-found" | "no-confirmed-member" };
+
+/**
+ * Read-only lookup of who a `/@/orgs//…` link would impersonate: the
+ * first organization member who has confirmed their basic details. Writes
+ * nothing, so it is safe to call while only rendering the consent page.
+ */
+export async function findImpersonationTarget(
+ organizationSlug: string,
+ prismaClient: PrismaClientOrTransaction = $replica
+): Promise {
+ const org = await prismaClient.organization.findFirst({
+ where: {
+ slug: organizationSlug,
+ deletedAt: null,
+ },
+ select: {
+ title: true,
+ members: {
+ select: {
+ user: {
+ select: {
+ id: true,
+ confirmedBasicDetails: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!org) {
+ return { success: false, reason: "org-not-found" };
+ }
+
+ const firstValidMember = org.members.find((m) => m.user.confirmedBasicDetails);
+
+ if (!firstValidMember) {
+ return { success: false, reason: "no-confirmed-member" };
+ }
+
+ return { success: true, userId: firstValidMember.user.id, organizationName: org.title };
+}
+
+/**
+ * Starts impersonating the organization's first confirmed member and lands on
+ * the requested path with the `/@` prefix stripped. Shared by the same-origin
+ * loader path and the consent page's POST so there is one implementation.
+ *
+ * The destination keeps the incoming query string: both entry points are served
+ * at the `/@`-prefixed URL, so `request.url` carries the same search the link
+ * arrived with (for example the `?span=` a `/@/runs/` link redirects with).
+ */
+export async function startImpersonation(
+ request: Request,
+ organizationSlug: string,
+ path: string,
+ currentUser: { id: string; admin: boolean },
+ clients: { read: PrismaClientOrTransaction; write: PrismaClientOrTransaction } = {
+ read: $replica,
+ write: prisma,
+ }
+) {
+ const target = await findImpersonationTarget(organizationSlug, clients.read);
+
+ if (!target.success) {
+ logger.debug("Cannot impersonate organization", { organizationSlug, reason: target.reason });
+ return clearImpersonation(request, "/admin");
+ }
+
+ return redirectWithImpersonation(
+ request,
+ target.userId,
+ impersonationDestinationPath(organizationSlug, path, new URL(request.url).search),
+ currentUser,
+ clients.write
+ );
+}
+
export async function clearImpersonation(request: Request, path: string) {
const authUser = await authenticator.isAuthenticated(request);
const targetId = await getImpersonationId(request);
diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx
index 839f44b4bbc..100d7d0c9a6 100644
--- a/apps/webapp/app/root.tsx
+++ b/apps/webapp/app/root.tsx
@@ -19,6 +19,7 @@ import { TimezoneSetter } from "./components/TimezoneSetter";
import { env } from "./env.server";
import { featuresForRequest } from "./features.server";
import { usePostHog } from "./hooks/usePostHog";
+import { getViewingAsUser } from "./services/impersonation.server";
import { getUser } from "./services/session.server";
import { getTimezonePreference } from "./services/preferences/uiPreferences.server";
import { appEnvTitleTag } from "./utils";
@@ -70,6 +71,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
};
const user = await getUser(request);
+ // Display-only: while impersonating, an admin can ask to see the dashboard
+ // the way the impersonated user sees it. Exposed from root so every route can
+ // read it.
+ const isViewingAsUser = await getViewingAsUser(request);
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
@@ -77,6 +82,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return typedjson(
{
user,
+ isViewingAsUser,
toastMessage,
posthogProjectKey,
posthogUiHost,
diff --git a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts
deleted file mode 100644
index a47deeac6aa..00000000000
--- a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect } from "remix-typedjson";
-import { $replica } from "~/db.server";
-import { clearImpersonation, redirectWithImpersonation } from "~/models/admin.server";
-import { env } from "~/env.server";
-import { logger } from "~/services/logger.server";
-import { requireUser } from "~/services/session.server";
-import { isSameOriginNavigation } from "~/utils/sameOriginNavigation";
-
-export async function loader({ request, params }: LoaderFunctionArgs) {
- const user = await requireUser(request);
-
- // If already impersonating, we need to clear the impersonation
- if (user.isImpersonating) {
- const url = new URL(request.url);
- return clearImpersonation(request, url.pathname);
- }
-
- // Only admins can impersonate
- if (!user.admin) {
- return redirect("/");
- }
-
- const path = params["*"];
- const organizationSlug = params.organizationSlug;
-
- logger.debug("Impersonating user", { path, organizationSlug });
-
- if (!organizationSlug) {
- logger.debug("Exiting impersonation mode");
- return clearImpersonation(request, "/admin");
- }
-
- // CSRF gate for the SET-impersonation path. Clearing impersonation
- // above is benign and stays reachable without the check.
- if (!isSameOriginNavigation(request, env.LOGIN_ORIGIN)) {
- logger.warn("Refusing cross-site impersonation entry", {
- userId: user.id,
- organizationSlug,
- referer: request.headers.get("referer"),
- secFetchSite: request.headers.get("sec-fetch-site"),
- });
- return redirect("/admin");
- }
-
- const org = await $replica.organization.findFirst({
- where: {
- slug: organizationSlug,
- deletedAt: null,
- },
- select: {
- members: {
- select: {
- user: {
- select: {
- id: true,
- confirmedBasicDetails: true,
- },
- },
- },
- },
- },
- });
-
- if (!org) {
- logger.debug("Organization not found", { organizationSlug });
- return clearImpersonation(request, "/admin");
- }
-
- const firstValidMember = org.members.find((m) => m.user.confirmedBasicDetails);
-
- if (!firstValidMember) {
- logger.debug("No valid members found", { organizationSlug });
- return clearImpersonation(request, "/admin");
- }
-
- return redirectWithImpersonation(
- request,
- firstValidMember.user.id,
- `/orgs/${organizationSlug}/${path}`
- );
-}
diff --git a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx
new file mode 100644
index 00000000000..6b3319d8e8e
--- /dev/null
+++ b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.tsx
@@ -0,0 +1,169 @@
+import { Form } from "@remix-run/react";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
+import { MainCenteredContainer } from "~/components/layout/AppLayout";
+import { Button } from "~/components/primitives/Buttons";
+import { Callout } from "~/components/primitives/Callout";
+import { Header1 } from "~/components/primitives/Headers";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { env } from "~/env.server";
+import {
+ clearImpersonation,
+ findImpersonationTarget,
+ startImpersonation,
+} from "~/models/admin.server";
+import { logger } from "~/services/logger.server";
+import { requireUser } from "~/services/session.server";
+import {
+ impersonationConsentPostBackPath,
+ impersonationDestinationPath,
+} from "~/utils/pathBuilder";
+import { isSameOriginNavigation } from "~/utils/sameOriginNavigation";
+
+// Everything this route's loader and action touch on the server lives in
+// `~/models/admin.server` on purpose: Remix only strips `loader`, `action` and
+// `headers` from a route module for the browser bundle, so any other export
+// here would drag server-only modules into the client build.
+
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ const user = await requireUser(request);
+
+ // If already impersonating, we need to clear the impersonation. Redirects are
+ // thrown, not returned, so the consent page below is the loader's only data
+ // shape.
+ if (user.isImpersonating) {
+ const url = new URL(request.url);
+ throw await clearImpersonation(request, url.pathname);
+ }
+
+ // Only admins can impersonate
+ if (!user.admin) {
+ throw redirect("/");
+ }
+
+ const path = params["*"] ?? "";
+ const organizationSlug = params.organizationSlug;
+
+ logger.debug("Impersonating user", { path, organizationSlug });
+
+ if (!organizationSlug) {
+ logger.debug("Exiting impersonation mode");
+ throw await clearImpersonation(request, "/admin");
+ }
+
+ // Starting impersonation is a state change, so it only happens straight away
+ // for an unambiguously same-origin navigation — that is what stops a
+ // cross-site navigation from silently starting impersonation. Links opened
+ // from outside the app (address bar, bookmark, a link shared elsewhere) get
+ // the consent page below instead, whose "Impersonate" button posts back from
+ // our own page and so satisfies the same check.
+ if (isSameOriginNavigation(request, env.LOGIN_ORIGIN)) {
+ throw await startImpersonation(request, organizationSlug, path, user);
+ }
+
+ // Expected for any link opened outside the app (address bar, bookmark, a link
+ // shared elsewhere), so this is routine rather than suspicious. Only the
+ // referer's origin is logged — the full referer can carry another site's path
+ // and query string.
+ logger.info("Impersonation entry outside the app, showing consent page", {
+ userId: user.id,
+ organizationSlug,
+ refererOrigin: refererOrigin(request),
+ secFetchSite: request.headers.get("sec-fetch-site"),
+ });
+
+ // Read-only on purpose: nothing is written and no impersonation cookie is set
+ // until the admin confirms with the POST below.
+ const target = await findImpersonationTarget(organizationSlug);
+
+ const search = new URL(request.url).search;
+
+ return typedjson({
+ organizationSlug,
+ organizationName: target.success ? target.organizationName : undefined,
+ destinationPath: impersonationDestinationPath(organizationSlug, path, search),
+ postBackPath: impersonationConsentPostBackPath(organizationSlug, path, search),
+ canImpersonate: target.success,
+ });
+}
+
+function refererOrigin(request: Request): string | undefined {
+ const referer = request.headers.get("referer");
+ if (!referer) return undefined;
+ try {
+ return new URL(referer).origin;
+ } catch {
+ return undefined;
+ }
+}
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ if (request.method.toLowerCase() !== "post") {
+ return new Response("Method not allowed", { status: 405 });
+ }
+
+ const user = await requireUser(request);
+
+ if (!user.admin) {
+ return redirect("/");
+ }
+
+ // The consent page posts from our own origin, so this holds. Re-applied here
+ // so another site cannot drive the POST either.
+ if (!isSameOriginNavigation(request, env.LOGIN_ORIGIN)) {
+ logger.warn("Refusing cross-site impersonation submission", {
+ userId: user.id,
+ organizationSlug: params.organizationSlug,
+ referer: request.headers.get("referer"),
+ secFetchSite: request.headers.get("sec-fetch-site"),
+ });
+ return redirect("/admin");
+ }
+
+ const organizationSlug = params.organizationSlug;
+
+ if (!organizationSlug) {
+ return clearImpersonation(request, "/admin");
+ }
+
+ // The consent form posts to an explicit absolute path (see
+ // `impersonationConsentPostBackPath`), so the organization slug, the splat
+ // path and the query string all arrive here intact.
+ return startImpersonation(request, organizationSlug, params["*"] ?? "", user);
+}
+
+export default function Page() {
+ const { organizationSlug, organizationName, destinationPath, postBackPath, canImpersonate } =
+ useTypedLoaderData();
+
+ return (
+
+
+ Impersonate
+ {canImpersonate ? (
+ <>
+
+ Continue to impersonate a member of{" "}
+ {organizationName ?? organizationSlug} and
+ open {destinationPath}.
+
+
+
+ Only continue if you meant to open this link. You'll be signed in as a member of this
+ organization until you stop impersonating.
+
+ >
+ ) : (
+
+ There's no organization {organizationSlug}{" "}
+ with a member you can impersonate.
+
+ )}
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx
index 153004813a8..6f7c1d2609c 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx
@@ -49,7 +49,7 @@ import { getTaskIdentifiers } from "~/models/task.server";
import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server";
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
import { removeFavoritesByUrlSubstring } from "~/services/dashboardPreferences.server";
-import { requireUser } from "~/services/session.server";
+import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
import {
EnvironmentParamSchema,
queryPath,
@@ -98,7 +98,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
]);
// Admins and impersonating users can use EXPLAIN
- const isAdmin = user.admin || user.isImpersonating;
+ const isAdmin = hasAdminDisplayAccess(user);
// Compute widget count from dashboard layout
const widgetCount = Object.keys(dashboard.layout.widgets).length;
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx
index da486898d4f..95f7b0ec946 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx
@@ -22,7 +22,7 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server";
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
-import { requireUser } from "~/services/session.server";
+import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
@@ -57,7 +57,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
new RegionsPresenter().call({
userId: user.id,
projectSlug: projectParam,
- isAdmin: user.admin || user.isImpersonating,
+ isAdmin: hasAdminDisplayAccess(user),
}),
]);
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx
index 848546a518f..6aecd22be81 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx
@@ -11,7 +11,7 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
import { executeQuery, getDefaultPeriod } from "~/services/queryService.server";
-import { requireUser } from "~/services/session.server";
+import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
import { EnvironmentParamSchema, queryPath } from "~/utils/pathBuilder";
import { canAccessQuery } from "~/v3/canAccessQuery.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
@@ -63,7 +63,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
// Admins and impersonating users can use EXPLAIN
- const isAdmin = user.admin || user.isImpersonating;
+ const isAdmin = hasAdminDisplayAccess(user);
return typedjson({
defaultQuery,
@@ -176,7 +176,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const { query, scope, explain: explainParam, period, from, to } = parsed.data;
// Only allow explain for admins/impersonating users
- const isAdmin = user.admin || user.isImpersonating;
+ const isAdmin = hasAdminDisplayAccess(user);
const explain = explainParam === "true" && isAdmin;
try {
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx
index 588647f22a7..7059ff6f9c9 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx
@@ -54,7 +54,8 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { findProjectBySlug } from "~/models/project.server";
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
-import { requireUser } from "~/services/session.server";
+import { getViewingAsUser } from "~/services/impersonation.server";
+import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import {
docsPath,
@@ -74,7 +75,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
presenter.call({
userId: user.id,
projectSlug: projectParam,
- isAdmin: user.admin || user.isImpersonating,
+ isAdmin: hasAdminDisplayAccess(user),
})
);
@@ -135,7 +136,10 @@ export const action = dashboardAction(
service.call({
projectId: project.id,
regionId: parsedFormData.data.regionId,
- isAdmin: user.admin || user.isImpersonating,
+ isAdmin: hasAdminDisplayAccess({
+ ...user,
+ isViewingAsUser: await getViewingAsUser(request),
+ }),
})
);
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
index 321d6391179..fc8ed9fb2d1 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
@@ -67,7 +67,7 @@ import {
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { logger } from "~/services/logger.server";
-import { requireUser } from "~/services/session.server";
+import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils/pathBuilder";
import { DeleteTaskRunTemplateService } from "~/v3/services/deleteTaskRunTemplate.server";
@@ -120,7 +120,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
new RegionsPresenter().call({
userId: user.id,
projectSlug: projectParam,
- isAdmin: user.admin || user.isImpersonating,
+ isAdmin: hasAdminDisplayAccess(user),
}),
]);
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx
index d22883caa12..9a2127489d4 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx
@@ -5,7 +5,7 @@ import { DashboardAgent } from "~/components/dashboard-agent/DashboardAgent";
import { prisma } from "~/db.server";
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
import { logger } from "~/services/logger.server";
-import { requireUser } from "~/services/session.server";
+import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
import { tenantContext } from "~/services/tenantContext.server";
import { EnvironmentParamSchema, v3ProjectPath } from "~/utils/pathBuilder";
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
@@ -91,10 +91,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
// the launcher button is hidden when it's not enabled. The org's featureFlags
// came from the membership-checked project query above, so we pass them in to
// avoid a second org lookup.
+ // Display-only, so it respects the "view as user" toggle: while that's on we
+ // hide the launcher an impersonated-into user wouldn't have.
+ const showAdminUi = hasAdminDisplayAccess(user);
const hasDashboardAgentAccess = await canAccessDashboardAgent({
userId: user.id,
- isAdmin: user.admin,
- isImpersonating: user.isImpersonating,
+ isAdmin: showAdminUi && user.admin,
+ isImpersonating: showAdminUi && user.isImpersonating,
organizationSlug,
orgFeatureFlags: (project.organization.featureFlags as Record) ?? {},
});
diff --git a/apps/webapp/app/routes/resources.impersonation_.view-as.ts b/apps/webapp/app/routes/resources.impersonation_.view-as.ts
new file mode 100644
index 00000000000..ab1ea68baf6
--- /dev/null
+++ b/apps/webapp/app/routes/resources.impersonation_.view-as.ts
@@ -0,0 +1,50 @@
+import { redirect, type ActionFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { env } from "~/env.server";
+import { commitImpersonationSession, setViewingAsUser } from "~/services/impersonation.server";
+import { logger } from "~/services/logger.server";
+import { requireUser } from "~/services/session.server";
+import { sanitizeRedirectPath } from "~/utils";
+import { isSameOriginNavigation } from "~/utils/sameOriginNavigation";
+
+const FormSchema = z.object({
+ viewAsUser: z.enum(["true", "false"]),
+ redirectTo: z.string().optional(),
+});
+
+export async function action({ request }: ActionFunctionArgs) {
+ if (request.method.toLowerCase() !== "post") {
+ return new Response("Method not allowed", { status: 405 });
+ }
+
+ const user = await requireUser(request);
+
+ // The toggle is submitted from our own side menu, so this holds. Applied here
+ // for the same reason as the other impersonation routes: no other site gets to
+ // drive this state change.
+ if (!isSameOriginNavigation(request, env.LOGIN_ORIGIN)) {
+ logger.warn("Refusing cross-site view-as-user submission", {
+ userId: user.id,
+ secFetchSite: request.headers.get("sec-fetch-site"),
+ });
+ return redirect("/");
+ }
+
+ const payload = Object.fromEntries(await request.formData());
+ const parsed = FormSchema.safeParse(payload);
+ const redirectTo = sanitizeRedirectPath(parsed.success ? parsed.data.redirectTo : undefined);
+
+ // Display-only toggle scoped to an impersonation session — outside one there
+ // is nothing to toggle.
+ if (!user.isImpersonating || !parsed.success) {
+ return redirect(redirectTo);
+ }
+
+ const session = await setViewingAsUser(parsed.data.viewAsUser === "true", request);
+
+ return redirect(redirectTo, {
+ headers: {
+ "Set-Cookie": await commitImpersonationSession(session),
+ },
+ });
+}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx
index 42631d5ff45..9bada394f26 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx
@@ -56,7 +56,9 @@ import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPrese
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
import { logger } from "~/services/logger.server";
+import { getViewingAsUser } from "~/services/impersonation.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
+import { hasAdminDisplayAccess } from "~/services/session.server";
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
import { cn } from "~/utils/cn";
import { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder";
@@ -84,6 +86,13 @@ export const loader = dashboardLoader(
throw new Response("Not Found", { status: 404 });
}
+ // Display only: hidden regions stay listed for admins, unless they've asked
+ // to see the dashboard the way the impersonated user sees it.
+ const isAdmin = hasAdminDisplayAccess({
+ ...user,
+ isViewingAsUser: await getViewingAsUser(request),
+ });
+
const presenter = new CreateBulkActionPresenter();
const [data, regionsResult] = await Promise.all([
presenter.call({
@@ -96,7 +105,7 @@ export const loader = dashboardLoader(
new RegionsPresenter().call({
userId: user.id,
projectSlug: projectParam,
- isAdmin: user.admin || user.isImpersonating,
+ isAdmin,
})
),
]);
diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
index d6fe04be99f..f113824075d 100644
--- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
+++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
@@ -8,7 +8,7 @@ import { $replica, prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
-import { requireUser } from "~/services/session.server";
+import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { sortEnvironments } from "~/utils/environmentSort";
import { v3RunSpanPath } from "~/utils/pathBuilder";
@@ -169,7 +169,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
new RegionsPresenter().call({
userId,
projectSlug,
- isAdmin: user.admin || user.isImpersonating,
+ isAdmin: hasAdminDisplayAccess(user),
}),
]);
diff --git a/apps/webapp/app/services/impersonation.server.ts b/apps/webapp/app/services/impersonation.server.ts
index a4f3ee59c9f..dd5127c65dd 100644
--- a/apps/webapp/app/services/impersonation.server.ts
+++ b/apps/webapp/app/services/impersonation.server.ts
@@ -18,6 +18,15 @@ export const impersonationSessionStorage = createCookieSessionStorage({
},
});
+const IMPERSONATED_USER_ID_KEY = "impersonatedUserId";
+
+/**
+ * Display-only "view as user" flag. It lives on the impersonation cookie so it
+ * is scoped to the impersonation session by construction: stop impersonating
+ * and the flag goes with it.
+ */
+const VIEWING_AS_USER_KEY = "viewingAsUser";
+
export function getImpersonationSession(request: Request) {
return impersonationSessionStorage.getSession(request.headers.get("Cookie"));
}
@@ -29,13 +38,13 @@ export function commitImpersonationSession(session: Session) {
export async function getImpersonationId(request: Request) {
const session = await getImpersonationSession(request);
- return session.get("impersonatedUserId") as string | undefined;
+ return session.get(IMPERSONATED_USER_ID_KEY) as string | undefined;
}
export async function setImpersonationId(userId: string, request: Request) {
const session = await getImpersonationSession(request);
- session.set("impersonatedUserId", userId);
+ session.set(IMPERSONATED_USER_ID_KEY, userId);
return session;
}
@@ -43,7 +52,35 @@ export async function setImpersonationId(userId: string, request: Request) {
export async function clearImpersonationId(request: Request) {
const session = await getImpersonationSession(request);
- session.unset("impersonatedUserId");
+ session.unset(IMPERSONATED_USER_ID_KEY);
+ // The view-as-user flag only means anything inside an impersonation session,
+ // so it never outlives one.
+ session.unset(VIEWING_AS_USER_KEY);
+
+ return session;
+}
+
+/**
+ * Whether the admin has asked to see the dashboard the way the impersonated
+ * user sees it. Only ever true inside an impersonation session.
+ */
+export async function getViewingAsUser(request: Request) {
+ const session = await getImpersonationSession(request);
+
+ return (
+ typeof session.get(IMPERSONATED_USER_ID_KEY) === "string" &&
+ session.get(VIEWING_AS_USER_KEY) === true
+ );
+}
+
+export async function setViewingAsUser(value: boolean, request: Request) {
+ const session = await getImpersonationSession(request);
+
+ if (value) {
+ session.set(VIEWING_AS_USER_KEY, true);
+ } else {
+ session.unset(VIEWING_AS_USER_KEY);
+ }
return session;
}
diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts
index bdd565cf2f9..1aa90b76a5f 100644
--- a/apps/webapp/app/services/session.server.ts
+++ b/apps/webapp/app/services/session.server.ts
@@ -3,7 +3,7 @@ import { getUserById } from "~/models/user.server";
import { sanitizeRedirectPath } from "~/utils";
import { extractClientIp } from "~/utils/extractClientIp.server";
import { authenticator } from "./auth.server";
-import { getImpersonationId } from "./impersonation.server";
+import { getImpersonationId, getViewingAsUser } from "./impersonation.server";
import { logger } from "./logger.server";
import { revalidateSsoSession } from "./ssoSessionRevalidation.server";
@@ -136,6 +136,7 @@ export async function requireUser(request: Request) {
}
const impersonationId = await getImpersonationId(request);
+ const isImpersonating = !!impersonationId && impersonationId === user.id;
return {
id: user.id,
email: user.email,
@@ -148,10 +149,26 @@ export async function requireUser(request: Request) {
dashboardPreferences: user.dashboardPreferences,
confirmedBasicDetails: user.confirmedBasicDetails,
mfaEnabledAt: user.mfaEnabledAt,
- isImpersonating: !!impersonationId && impersonationId === user.id,
+ isImpersonating,
+ isViewingAsUser: isImpersonating && (await getViewingAsUser(request)),
};
}
+/**
+ * Whether admin-only UI should be rendered for this user.
+ *
+ * Display only. The "view as user" toggle is cosmetic and must never widen or
+ * narrow a real security boundary — authorization stays on `user.admin`, the
+ * route builder's `authorization` block and the per-feature access checks.
+ */
+export function hasAdminDisplayAccess(user: {
+ admin: boolean;
+ isImpersonating: boolean;
+ isViewingAsUser: boolean;
+}): boolean {
+ return (user.admin || user.isImpersonating) && !user.isViewingAsUser;
+}
+
export async function logout(request: Request) {
return redirect("/logout");
}
diff --git a/apps/webapp/app/utils/impersonationPaths.test.ts b/apps/webapp/app/utils/impersonationPaths.test.ts
new file mode 100644
index 00000000000..e3dba8c8241
--- /dev/null
+++ b/apps/webapp/app/utils/impersonationPaths.test.ts
@@ -0,0 +1,119 @@
+import { matchRoutes, resolveTo } from "@remix-run/router";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { impersonationConsentPostBackPath, impersonationDestinationPath } from "./pathBuilder";
+
+// The route that renders the impersonation consent page, as the flat-route
+// convention compiles `_app.@.orgs.$organizationSlug.$.tsx`.
+const CONSENT_ROUTES = [
+ {
+ id: "routes/_app",
+ children: [
+ {
+ id: "routes/_app.@.orgs.$organizationSlug.$",
+ path: "@/orgs/:organizationSlug/*",
+ },
+ ],
+ },
+];
+
+/**
+ * What the consent route's `action` would receive for a POST to `pathname`:
+ * the organization slug and the splat, straight off the router.
+ */
+function actionParamsFor(pathname: string) {
+ const matches = matchRoutes(CONSENT_ROUTES, pathname);
+ const leaf = matches?.[matches.length - 1];
+ return {
+ organizationSlug: leaf?.params.organizationSlug,
+ splat: leaf?.params["*"],
+ };
+}
+
+/**
+ * What `