Skip to content

Commit a24b49f

Browse files
committed
fix(webapp): drop the impersonation accent in view-as-user mode, keep capability gates raw
The yellow side-menu border is the loudest tell that you are not the user, so "view as user" now hides it too — the point of the mode is a dashboard that looks exactly like the user's. The ways out stay on raw impersonation: "Stop impersonating" and the toggle itself in the account menu, the Cmd+Opt+A shortcut, and the routes that clear the impersonation cookie. The write suppressions (side-menu preferences, favorites) also stay raw — they keep an admin from writing into the impersonated account and are not display chrome. Two more review findings, same area: - The already-impersonating branch of the impersonation deep link kept only the pathname, so an admin who was already impersonating and opened a run link lost the ?span= selection and the follow-up GET built its destination and post-back paths from an empty search. Keep the search. - hasAdminDisplayAccess is display-only, but it had reached two gates that decide what a request may do: the default-region service call and the query page's EXPLAIN flag. With the toggle on, an admin's own form submissions were rejected. Both are back on raw user.admin || user.isImpersonating, and the helper now states the rule: the toggle changes what is shown, never what is permitted. Every loader and rendering site stays on the helper. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9d12b5f commit a24b49f

6 files changed

Lines changed: 27 additions & 11 deletions

File tree

.server-changes/impersonation-consent-and-view-as-user.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: improvement
44
---
55

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.
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/navigation/SideMenu.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ export function SideMenu({
398398
const { isConnected } = useDevPresence();
399399
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
400400
const isAdmin = useHasAdminAccess();
401+
const isViewingAsUser = useIsViewingAsUser();
401402
const { isManagedCloud } = useFeatures();
402403
const featureFlags = useFeatureFlags();
403404
const incidentStatus = useIncidentStatus();
@@ -1057,7 +1058,13 @@ export function SideMenu({
10571058
style={initialStyleRef.current}
10581059
className={cn(
10591060
"relative h-full border-r bg-background-bright",
1060-
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"
10611068
)}
10621069
>
10631070
<ResizeHandle

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
3333
// shape.
3434
if (user.isImpersonating) {
3535
const url = new URL(request.url);
36-
throw await clearImpersonation(request, url.pathname);
36+
// Keep the search: `/@/runs/<id>` links redirect here carrying `?span=<spanId>`, and the
37+
// follow-up GET builds the destination and post-back paths from it. Dropping it would land the
38+
// admin on the run with no span selected.
39+
throw await clearImpersonation(request, `${url.pathname}${url.search}`);
3740
}
3841

3942
// Only admins can impersonate

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
175175
}
176176

177177
const { query, scope, explain: explainParam, period, from, to } = parsed.data;
178-
// Only allow explain for admins/impersonating users
179-
const isAdmin = hasAdminDisplayAccess(user);
180-
const explain = explainParam === "true" && isAdmin;
178+
// Only allow explain for admins/impersonating users. Raw impersonation, not
179+
// `hasAdminDisplayAccess`: this decides what the request may run, and "view as user" only changes
180+
// what is shown — the loader is what hides the EXPLAIN control.
181+
const explain = explainParam === "true" && (user.admin || user.isImpersonating);
181182

182183
try {
183184
const queryResult = await executeQuery({

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
5454
import { resolveOrgIdFromSlug } from "~/models/organization.server";
5555
import { findProjectBySlug } from "~/models/project.server";
5656
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
57-
import { getViewingAsUser } from "~/services/impersonation.server";
5857
import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
5958
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
6059
import {
@@ -136,10 +135,10 @@ export const action = dashboardAction(
136135
service.call({
137136
projectId: project.id,
138137
regionId: parsedFormData.data.regionId,
139-
isAdmin: hasAdminDisplayAccess({
140-
...user,
141-
isViewingAsUser: await getViewingAsUser(request),
142-
}),
138+
// Raw impersonation, not `hasAdminDisplayAccess`: this decides whether a restricted or
139+
// hidden region may be set as the default, which is a capability. "View as user" only
140+
// changes what is shown.
141+
isAdmin: user.admin || user.isImpersonating,
143142
})
144143
);
145144

apps/webapp/app/services/session.server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ export async function requireUser(request: Request) {
160160
* Display only. The "view as user" toggle is cosmetic and must never widen or
161161
* narrow a real security boundary — authorization stays on `user.admin`, the
162162
* route builder's `authorization` block and the per-feature access checks.
163+
*
164+
* The rule: the toggle changes what is *shown*, never what is *permitted*. So
165+
* call this from loaders and rendering only. Anything deciding what a request
166+
* may do — an action, a service call that enforces something — stays on raw
167+
* `user.admin || user.isImpersonating`, or the admin's own form submissions
168+
* start failing the moment they flip the toggle on.
163169
*/
164170
export function hasAdminDisplayAccess(user: {
165171
admin: boolean;

0 commit comments

Comments
 (0)