Skip to content

Commit debfa2b

Browse files
authored
feat(webapp): impersonation consent page and a view-as-user toggle (#4421)
1 parent 68ed809 commit debfa2b

28 files changed

Lines changed: 1096 additions & 165 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
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.

apps/webapp/app/components/ImpersonationBanner.tsx

Lines changed: 0 additions & 30 deletions
This file was deleted.

apps/webapp/app/components/admin/debugRun.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useIsImpersonating } from "~/hooks/useOrganizations";
21
import { useHasAdminAccess } from "~/hooks/useUser";
32
import { Button } from "../primitives/Buttons";
43
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
@@ -12,10 +11,11 @@ import * as Property from "~/components/primitives/PropertyTable";
1211
import { ClipboardField } from "../primitives/ClipboardField";
1312

1413
export function AdminDebugRun({ friendlyId }: { friendlyId: string }) {
14+
// `useHasAdminAccess` already folds in impersonation and the "view as user"
15+
// toggle, so this one check is enough.
1516
const hasAdminAccess = useHasAdminAccess();
16-
const isImpersonating = useIsImpersonating();
1717

18-
if (!hasAdminAccess && !isImpersonating) {
18+
if (!hasAdminAccess) {
1919
return null;
2020
}
2121

apps/webapp/app/components/admin/debugTooltip.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ import {
88
TooltipTrigger,
99
} from "~/components/primitives/Tooltip";
1010
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
11-
import { useIsImpersonating, useOptionalOrganization } from "~/hooks/useOrganizations";
11+
import { useOptionalOrganization } from "~/hooks/useOrganizations";
1212
import { useOptionalProject } from "~/hooks/useProject";
1313
import { useHasAdminAccess, useUser } from "~/hooks/useUser";
1414

1515
export function AdminDebugTooltip({ children }: { children?: React.ReactNode }) {
16+
// `useHasAdminAccess` already folds in impersonation and the "view as user"
17+
// toggle, so this one check is enough.
1618
const hasAdminAccess = useHasAdminAccess();
17-
const isImpersonating = useIsImpersonating();
1819

19-
if (!hasAdminAccess && !isImpersonating) {
20+
if (!hasAdminAccess) {
2021
return null;
2122
}
2223

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import {
44
ExclamationTriangleIcon,
55
} from "@heroicons/react/24/outline";
66
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
7-
import { useFetcher, useNavigation, useRevalidator, useSubmit } from "@remix-run/react";
7+
import {
8+
Form,
9+
useFetcher,
10+
useLocation,
11+
useNavigation,
12+
useRevalidator,
13+
useSubmit,
14+
} from "@remix-run/react";
815
import { LayoutGroup, motion } from "framer-motion";
916
import {
1017
type CSSProperties,
@@ -33,6 +40,8 @@ import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon";
3340
import { DialIcon } from "~/assets/icons/DialIcon";
3441
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
3542
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
43+
import { EyeClosedIcon } from "~/assets/icons/EyeClosedIcon";
44+
import { EyeOpenIcon } from "~/assets/icons/EyeOpenIcon";
3645
import { FolderClosedIcon } from "~/assets/icons/FolderClosedIcon";
3746
import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon";
3847
import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon";
@@ -69,7 +78,7 @@ import { type MatchedOrganization } from "~/hooks/useOrganizations";
6978
import { type MatchedProject } from "~/hooks/useProject";
7079
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
7180
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
72-
import { useHasAdminAccess } from "~/hooks/useUser";
81+
import { useHasAdminAccess, useIsViewingAsUser } from "~/hooks/useUser";
7382
import { type UserWithDashboardPreferences } from "~/models/user.server";
7483
import {
7584
useCurrentPlan,
@@ -389,6 +398,7 @@ export function SideMenu({
389398
const { isConnected } = useDevPresence();
390399
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
391400
const isAdmin = useHasAdminAccess();
401+
const isViewingAsUser = useIsViewingAsUser();
392402
const { isManagedCloud } = useFeatures();
393403
const featureFlags = useFeatureFlags();
394404
const incidentStatus = useIncidentStatus();
@@ -785,7 +795,7 @@ export function SideMenu({
785795
// user's saved order/hidden preferences are applied at render below.
786796
const staticSections: SideMenuSectionConfig[] = [];
787797

788-
if (user.admin || user.isImpersonating || featureFlags.hasAiAccess) {
798+
if (isAdmin || featureFlags.hasAiAccess) {
789799
staticSections.push({
790800
id: "ai",
791801
title: "AI",
@@ -813,12 +823,12 @@ export function SideMenu({
813823
});
814824
}
815825

816-
if (user.admin || user.isImpersonating || featureFlags.hasQueryAccess) {
826+
if (isAdmin || featureFlags.hasQueryAccess) {
817827
staticSections.push({
818828
id: "metrics",
819829
title: "Observability",
820830
items: [
821-
...(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess
831+
...(isAdmin || featureFlags.hasLogsPageAccess
822832
? [
823833
{
824834
id: "logs",
@@ -1048,7 +1058,13 @@ export function SideMenu({
10481058
style={initialStyleRef.current}
10491059
className={cn(
10501060
"relative h-full border-r bg-background-bright",
1051-
user.isImpersonating ? IMPERSONATION_ACCENT.border : "border-grid-bright"
1061+
// The accent is the loudest "you are not this user" tell, so "view as user" drops it too —
1062+
// the point of the mode is a dashboard that looks exactly like the user's. The account
1063+
// menu's "Stop impersonating" and the toggle itself stay on raw impersonation, so there is
1064+
// still a way back out (as does the ⌘⌥A shortcut in <GlobalShortcuts>).
1065+
user.isImpersonating && !isViewingAsUser
1066+
? IMPERSONATION_ACCENT.border
1067+
: "border-grid-bright"
10521068
)}
10531069
>
10541070
<ResizeHandle
@@ -1832,24 +1848,29 @@ function AccountMenuItems({
18321848

18331849
return (
18341850
<>
1835-
{isAdmin && (
1851+
{/* "Stop impersonating" and the view-as-user toggle key off raw impersonation, not `isAdmin`:
1852+
with "view as user" on, `isAdmin` is false and these are the only ways back out. */}
1853+
{(isImpersonating || isAdmin) && (
18361854
<div className="flex flex-col gap-1 border-b border-grid-bright p-1">
18371855
{isImpersonating ? (
1838-
<PopoverMenuItem
1839-
title={
1840-
<div className="flex w-full items-center justify-between">
1841-
<span className={IMPERSONATION_ACCENT.text}>Stop impersonating</span>
1842-
<ShortcutKey
1843-
shortcut={{ modifiers: ["mod", "alt"], key: "a" }}
1844-
variant="medium/bright"
1845-
/>
1846-
</div>
1847-
}
1848-
icon={UserCrossIcon}
1849-
onClick={stopImpersonating}
1850-
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
1851-
className={SIDE_MENU_POPOVER_ITEM_LABEL}
1852-
/>
1856+
<>
1857+
<PopoverMenuItem
1858+
title={
1859+
<div className="flex w-full items-center justify-between">
1860+
<span className={IMPERSONATION_ACCENT.text}>Stop impersonating</span>
1861+
<ShortcutKey
1862+
shortcut={{ modifiers: ["mod", "alt"], key: "a" }}
1863+
variant="medium/bright"
1864+
/>
1865+
</div>
1866+
}
1867+
icon={UserCrossIcon}
1868+
onClick={stopImpersonating}
1869+
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
1870+
className={SIDE_MENU_POPOVER_ITEM_LABEL}
1871+
/>
1872+
<ViewAsUserMenuItem />
1873+
</>
18531874
) : (
18541875
<PopoverMenuItem
18551876
to={adminPath()}
@@ -1906,6 +1927,30 @@ function AccountMenuItems({
19061927
);
19071928
}
19081929

1930+
/**
1931+
* Toggles the display-only "view as user" mode for the current impersonation session, so an admin
1932+
* can see the dashboard the way the impersonated user sees it. `reloadDocument` forces a full
1933+
* navigation, so every loader re-runs under the updated cookie instead of reusing cached data.
1934+
*/
1935+
function ViewAsUserMenuItem() {
1936+
const isViewingAsUser = useIsViewingAsUser();
1937+
const location = useLocation();
1938+
1939+
return (
1940+
<Form method="post" action="/resources/impersonation/view-as" reloadDocument>
1941+
<input type="hidden" name="viewAsUser" value={isViewingAsUser ? "false" : "true"} />
1942+
<input type="hidden" name="redirectTo" value={`${location.pathname}${location.search}`} />
1943+
<PopoverMenuItem
1944+
type="submit"
1945+
title={isViewingAsUser ? "Show admin UI" : "View as user"}
1946+
icon={isViewingAsUser ? EyeClosedIcon : EyeOpenIcon}
1947+
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
1948+
className={SIDE_MENU_POPOVER_ITEM_LABEL}
1949+
/>
1950+
</Form>
1951+
);
1952+
}
1953+
19091954
function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImpersonating: boolean }) {
19101955
const [isOpen, setIsOpen] = useState(false);
19111956
const navigation = useNavigation();

apps/webapp/app/hooks/useUser.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,23 @@ export function useUserChanged(callback: (user: User | undefined) => void) {
3030
useChanged(useOptionalUser, callback);
3131
}
3232

33+
/**
34+
* Whether the admin has switched to "view as user" for the current
35+
* impersonation session. Display only — see `hasAdminDisplayAccess`.
36+
*/
37+
export function useIsViewingAsUser(matches?: UIMatch[]): boolean {
38+
const routeMatch = useTypedMatchesData<typeof loader>({
39+
id: "root",
40+
matches,
41+
});
42+
43+
return routeMatch?.isViewingAsUser === true;
44+
}
45+
3346
export function useHasAdminAccess(matches?: UIMatch[]): boolean {
3447
const user = useOptionalUser(matches);
3548
const isImpersonating = useIsImpersonating(matches);
49+
const isViewingAsUser = useIsViewingAsUser(matches);
3650

37-
return Boolean(user?.admin) || isImpersonating;
51+
return (Boolean(user?.admin) || isImpersonating) && !isViewingAsUser;
3852
}

apps/webapp/app/models/admin.server.ts

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { redirect } from "@remix-run/server-runtime";
2-
import { prisma } from "~/db.server";
2+
import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
33
import { logger } from "~/services/logger.server";
44
import type { SearchParams } from "~/routes/admin._index";
55
import {
@@ -11,6 +11,7 @@ import {
1111
import { authenticator } from "~/services/auth.server";
1212
import { requireUser } from "~/services/session.server";
1313
import { extractClientIp } from "~/utils/extractClientIp.server";
14+
import { impersonationDestinationPath } from "~/utils/pathBuilder";
1415

1516
const pageSize = 20;
1617

@@ -213,7 +214,8 @@ export async function redirectWithImpersonation(
213214
request: Request,
214215
userId: string,
215216
path: string,
216-
currentUser?: { id: string; admin: boolean }
217+
currentUser?: { id: string; admin: boolean },
218+
prismaClient: PrismaClientOrTransaction = prisma
217219
) {
218220
const user = currentUser ?? (await requireUser(request));
219221
if (!user.admin) {
@@ -224,7 +226,7 @@ export async function redirectWithImpersonation(
224226
const ipAddress = extractClientIp(xff);
225227

226228
try {
227-
await prisma.impersonationAuditLog.create({
229+
await prismaClient.impersonationAuditLog.create({
228230
data: {
229231
action: "START",
230232
adminId: user.id,
@@ -247,6 +249,87 @@ export async function redirectWithImpersonation(
247249
});
248250
}
249251

252+
type ImpersonationTarget =
253+
| { success: true; userId: string; organizationName: string }
254+
| { success: false; reason: "org-not-found" | "no-confirmed-member" };
255+
256+
/**
257+
* Read-only lookup of who a `/@/orgs/<slug>/…` link would impersonate: the
258+
* first organization member who has confirmed their basic details. Writes
259+
* nothing, so it is safe to call while only rendering the consent page.
260+
*/
261+
export async function findImpersonationTarget(
262+
organizationSlug: string,
263+
prismaClient: PrismaClientOrTransaction = $replica
264+
): Promise<ImpersonationTarget> {
265+
const org = await prismaClient.organization.findFirst({
266+
where: {
267+
slug: organizationSlug,
268+
deletedAt: null,
269+
},
270+
select: {
271+
title: true,
272+
members: {
273+
select: {
274+
user: {
275+
select: {
276+
id: true,
277+
confirmedBasicDetails: true,
278+
},
279+
},
280+
},
281+
},
282+
},
283+
});
284+
285+
if (!org) {
286+
return { success: false, reason: "org-not-found" };
287+
}
288+
289+
const firstValidMember = org.members.find((m) => m.user.confirmedBasicDetails);
290+
291+
if (!firstValidMember) {
292+
return { success: false, reason: "no-confirmed-member" };
293+
}
294+
295+
return { success: true, userId: firstValidMember.user.id, organizationName: org.title };
296+
}
297+
298+
/**
299+
* Starts impersonating the organization's first confirmed member and lands on
300+
* the requested path with the `/@` prefix stripped. Shared by the same-origin
301+
* loader path and the consent page's POST so there is one implementation.
302+
*
303+
* The destination keeps the incoming query string: both entry points are served
304+
* at the `/@`-prefixed URL, so `request.url` carries the same search the link
305+
* arrived with (for example the `?span=` a `/@/runs/<id>` link redirects with).
306+
*/
307+
export async function startImpersonation(
308+
request: Request,
309+
organizationSlug: string,
310+
path: string,
311+
currentUser: { id: string; admin: boolean },
312+
clients: { read: PrismaClientOrTransaction; write: PrismaClientOrTransaction } = {
313+
read: $replica,
314+
write: prisma,
315+
}
316+
) {
317+
const target = await findImpersonationTarget(organizationSlug, clients.read);
318+
319+
if (!target.success) {
320+
logger.debug("Cannot impersonate organization", { organizationSlug, reason: target.reason });
321+
return clearImpersonation(request, "/admin");
322+
}
323+
324+
return redirectWithImpersonation(
325+
request,
326+
target.userId,
327+
impersonationDestinationPath(organizationSlug, path, new URL(request.url).search),
328+
currentUser,
329+
clients.write
330+
);
331+
}
332+
250333
export async function clearImpersonation(request: Request, path: string) {
251334
const authUser = await authenticator.isAuthenticated(request);
252335
const targetId = await getImpersonationId(request);

0 commit comments

Comments
 (0)