Skip to content

Commit dcd20a7

Browse files
committed
feat(webapp): impersonation consent page and a view-as-user toggle
Opening a `/@/orgs/<slug>/…` link from outside the dashboard (address bar, bookmark, a link shared elsewhere) used to bounce back to /admin, because starting impersonation requires a same-origin navigation. Keep that requirement for the state change, but render a consent interstitial instead of blocking: the page names the organization and destination, and its "Impersonate" button posts back from our own page, so the same-origin check still holds. In-app admin links are unchanged and still impersonate in one click. Also add a display-only "View as user" toggle for impersonation sessions. It lives on the impersonation cookie, so it disappears when impersonation is cleared, and it hides admin-only UI both client-side (via useHasAdminAccess) and in the loaders that compute what to show. It never touches authorization. The escape hatches — the impersonation border, "Stop impersonating", the global shortcut and the toggle itself — stay visible while it's on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5rpYGm4SVqxSNXx6sXtpu
1 parent 2f1734c commit dcd20a7

19 files changed

Lines changed: 671 additions & 128 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 without any admin-only UI.

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

Lines changed: 59 additions & 21 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,
@@ -785,7 +794,7 @@ export function SideMenu({
785794
// user's saved order/hidden preferences are applied at render below.
786795
const staticSections: SideMenuSectionConfig[] = [];
787796

788-
if (user.admin || user.isImpersonating || featureFlags.hasAiAccess) {
797+
if (isAdmin || featureFlags.hasAiAccess) {
789798
staticSections.push({
790799
id: "ai",
791800
title: "AI",
@@ -813,12 +822,12 @@ export function SideMenu({
813822
});
814823
}
815824

816-
if (user.admin || user.isImpersonating || featureFlags.hasQueryAccess) {
825+
if (isAdmin || featureFlags.hasQueryAccess) {
817826
staticSections.push({
818827
id: "metrics",
819828
title: "Observability",
820829
items: [
821-
...(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess
830+
...(isAdmin || featureFlags.hasLogsPageAccess
822831
? [
823832
{
824833
id: "logs",
@@ -1832,24 +1841,29 @@ function AccountMenuItems({
18321841

18331842
return (
18341843
<>
1835-
{isAdmin && (
1844+
{/* "Stop impersonating" and the view-as-user toggle key off raw impersonation, not `isAdmin`:
1845+
with "view as user" on, `isAdmin` is false and these are the only ways back out. */}
1846+
{(isImpersonating || isAdmin) && (
18361847
<div className="flex flex-col gap-1 border-b border-grid-bright p-1">
18371848
{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-
/>
1849+
<>
1850+
<PopoverMenuItem
1851+
title={
1852+
<div className="flex w-full items-center justify-between">
1853+
<span className={IMPERSONATION_ACCENT.text}>Stop impersonating</span>
1854+
<ShortcutKey
1855+
shortcut={{ modifiers: ["mod", "alt"], key: "a" }}
1856+
variant="medium/bright"
1857+
/>
1858+
</div>
1859+
}
1860+
icon={UserCrossIcon}
1861+
onClick={stopImpersonating}
1862+
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
1863+
className={SIDE_MENU_POPOVER_ITEM_LABEL}
1864+
/>
1865+
<ViewAsUserMenuItem />
1866+
</>
18531867
) : (
18541868
<PopoverMenuItem
18551869
to={adminPath()}
@@ -1906,6 +1920,30 @@ function AccountMenuItems({
19061920
);
19071921
}
19081922

1923+
/**
1924+
* Toggles the display-only "view as user" mode for the current impersonation session, so an admin
1925+
* can see the dashboard the way the impersonated user sees it. `reloadDocument` forces a full
1926+
* navigation, so every loader re-runs under the updated cookie instead of reusing cached data.
1927+
*/
1928+
function ViewAsUserMenuItem() {
1929+
const isViewingAsUser = useIsViewingAsUser();
1930+
const location = useLocation();
1931+
1932+
return (
1933+
<Form method="post" action="/resources/impersonation/view-as" reloadDocument>
1934+
<input type="hidden" name="viewAsUser" value={isViewingAsUser ? "false" : "true"} />
1935+
<input type="hidden" name="redirectTo" value={`${location.pathname}${location.search}`} />
1936+
<PopoverMenuItem
1937+
type="submit"
1938+
title={isViewingAsUser ? "Show admin UI" : "View as user"}
1939+
icon={isViewingAsUser ? EyeClosedIcon : EyeOpenIcon}
1940+
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, IMPERSONATION_ACCENT.text)}
1941+
className={SIDE_MENU_POPOVER_ITEM_LABEL}
1942+
/>
1943+
</Form>
1944+
);
1945+
}
1946+
19091947
function AccountMenu({ isAdmin, isImpersonating }: { isAdmin: boolean; isImpersonating: boolean }) {
19101948
const [isOpen, setIsOpen] = useState(false);
19111949
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: 4 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 { prisma, type PrismaClientOrTransaction } from "~/db.server";
33
import { logger } from "~/services/logger.server";
44
import type { SearchParams } from "~/routes/admin._index";
55
import {
@@ -213,7 +213,8 @@ export async function redirectWithImpersonation(
213213
request: Request,
214214
userId: string,
215215
path: string,
216-
currentUser?: { id: string; admin: boolean }
216+
currentUser?: { id: string; admin: boolean },
217+
prismaClient: PrismaClientOrTransaction = prisma
217218
) {
218219
const user = currentUser ?? (await requireUser(request));
219220
if (!user.admin) {
@@ -224,7 +225,7 @@ export async function redirectWithImpersonation(
224225
const ipAddress = extractClientIp(xff);
225226

226227
try {
227-
await prisma.impersonationAuditLog.create({
228+
await prismaClient.impersonationAuditLog.create({
228229
data: {
229230
action: "START",
230231
adminId: user.id,

apps/webapp/app/root.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { TimezoneSetter } from "./components/TimezoneSetter";
1919
import { env } from "./env.server";
2020
import { featuresForRequest } from "./features.server";
2121
import { usePostHog } from "./hooks/usePostHog";
22+
import { getViewingAsUser } from "./services/impersonation.server";
2223
import { getUser } from "./services/session.server";
2324
import { getTimezonePreference } from "./services/preferences/uiPreferences.server";
2425
import { appEnvTitleTag } from "./utils";
@@ -70,13 +71,18 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
7071
};
7172

7273
const user = await getUser(request);
74+
// Display-only: while impersonating, an admin can ask to see the dashboard
75+
// the way the impersonated user sees it. Exposed from root so every route can
76+
// read it.
77+
const isViewingAsUser = await getViewingAsUser(request);
7378

7479
const headers = new Headers();
7580
headers.append("Set-Cookie", await commitSession(session));
7681

7782
return typedjson(
7883
{
7984
user,
85+
isViewingAsUser,
8086
toastMessage,
8187
posthogProjectKey,
8288
posthogUiHost,

apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts

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

0 commit comments

Comments
 (0)