Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 without any admin-only UI.
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
80 changes: 59 additions & 21 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 @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) && (
<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 +1920,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;
}
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
6 changes: 6 additions & 0 deletions apps/webapp/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -70,13 +71,18 @@ 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));

return typedjson(
{
user,
isViewingAsUser,
toastMessage,
posthogProjectKey,
posthogUiHost,
Expand Down
Loading