Skip to content
Merged
6 changes: 6 additions & 0 deletions .server-changes/impersonation-consent-and-view-as-user.md
Original file line number Diff line number Diff line change
@@ -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 exactly as that user sees it, with the admin-only UI and the impersonation highlight both hidden. Stopping impersonation is still one click away in the account menu.
30 changes: 0 additions & 30 deletions apps/webapp/app/components/ImpersonationBanner.tsx

This file was deleted.

6 changes: 3 additions & 3 deletions apps/webapp/app/components/admin/debugRun.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}

Expand Down
7 changes: 4 additions & 3 deletions apps/webapp/app/components/admin/debugTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
89 changes: 67 additions & 22 deletions apps/webapp/app/components/navigation/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -389,6 +398,7 @@ export function SideMenu({
const { isConnected } = useDevPresence();
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
const isAdmin = useHasAdminAccess();
const isViewingAsUser = useIsViewingAsUser();
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
const incidentStatus = useIncidentStatus();
Expand Down Expand Up @@ -785,7 +795,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",
Expand Down Expand Up @@ -813,12 +823,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",
Expand Down Expand Up @@ -1048,7 +1058,13 @@ export function SideMenu({
style={initialStyleRef.current}
className={cn(
"relative h-full border-r bg-background-bright",
user.isImpersonating ? IMPERSONATION_ACCENT.border : "border-grid-bright"
// The accent is the loudest "you are not this user" tell, so "view as user" drops it too —
// the point of the mode is a dashboard that looks exactly like the user's. The account
// menu's "Stop impersonating" and the toggle itself stay on raw impersonation, so there is
// still a way back out (as does the ⌘⌥A shortcut in <GlobalShortcuts>).
user.isImpersonating && !isViewingAsUser
? IMPERSONATION_ACCENT.border
: "border-grid-bright"
Comment thread
carderne marked this conversation as resolved.
Comment thread
carderne marked this conversation as resolved.
)}
>
<ResizeHandle
Expand Down Expand Up @@ -1832,24 +1848,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) && (
<div className="flex flex-col gap-1 border-b border-grid-bright p-1">
{isImpersonating ? (
<PopoverMenuItem
title={
<div className="flex w-full items-center justify-between">
<span className={IMPERSONATION_ACCENT.text}>Stop impersonating</span>
<ShortcutKey
shortcut={{ modifiers: ["mod", "alt"], key: "a" }}
variant="medium/bright"
/>
</div>
}
icon={UserCrossIcon}
onClick={stopImpersonating}
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
className={SIDE_MENU_POPOVER_ITEM_LABEL}
/>
<>
<PopoverMenuItem
title={
<div className="flex w-full items-center justify-between">
<span className={IMPERSONATION_ACCENT.text}>Stop impersonating</span>
<ShortcutKey
shortcut={{ modifiers: ["mod", "alt"], key: "a" }}
variant="medium/bright"
/>
</div>
}
icon={UserCrossIcon}
onClick={stopImpersonating}
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
className={SIDE_MENU_POPOVER_ITEM_LABEL}
/>
<ViewAsUserMenuItem />
</>
) : (
<PopoverMenuItem
to={adminPath()}
Expand Down Expand Up @@ -1906,6 +1927,30 @@ function AccountMenuItems({
);
}

/**
* Toggles the display-only "view as user" mode for the current impersonation session, so an admin
* can see the dashboard the way the impersonated user sees it. `reloadDocument` forces a full
* navigation, so every loader re-runs under the updated cookie instead of reusing cached data.
*/
function ViewAsUserMenuItem() {
const isViewingAsUser = useIsViewingAsUser();
const location = useLocation();

return (
<Form method="post" action="/resources/impersonation/view-as" reloadDocument>
<input type="hidden" name="viewAsUser" value={isViewingAsUser ? "false" : "true"} />
<input type="hidden" name="redirectTo" value={`${location.pathname}${location.search}`} />
<PopoverMenuItem
type="submit"
title={isViewingAsUser ? "Show admin UI" : "View as user"}
icon={isViewingAsUser ? EyeClosedIcon : EyeOpenIcon}
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
className={SIDE_MENU_POPOVER_ITEM_LABEL}
/>
</Form>
);
}

function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImpersonating: boolean }) {
const [isOpen, setIsOpen] = useState(false);
const navigation = useNavigation();
Expand Down
16 changes: 15 additions & 1 deletion apps/webapp/app/hooks/useUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof loader>({
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;
}
Comment thread
carderne marked this conversation as resolved.
89 changes: 86 additions & 3 deletions apps/webapp/app/models/admin.server.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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/<slug>/…` 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<ImpersonationTarget> {
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/<id>` 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);
Expand Down
Loading