Skip to content

feat(webapp): impersonation consent page and a view-as-user toggle - #4421

Draft
claude[bot] wants to merge 4 commits into
mainfrom
claude/impersonation-consent-and-view-as-user
Draft

feat(webapp): impersonation consent page and a view-as-user toggle#4421
claude[bot] wants to merge 4 commits into
mainfrom
claude/impersonation-consent-and-view-as-user

Conversation

@claude

@claude claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Requested by Chris Arderne, Eric Allam · Slack thread

Before / After

Impersonation links opened from outside the dashboard

  • Before: an admin pasting a /@/orgs/<slug>/… link into the address bar, opening it from a bookmark, or clicking it from a message got silently bounced to /admin. Starting impersonation requires a same-origin navigation (so a cross-site navigation can't silently start it), and those entries don't qualify — so the whole link-sharing workflow was broken.
  • After: the same link lands on a small confirmation page that names the organization and the page you're about to open, with a single Impersonate button (Enter also works). Clicking it starts impersonation and drops you on the page you originally asked for. Nothing happens until you click — no impersonation session is started and nothing is recorded just by opening the link. Admin links inside the dashboard are unchanged: still one click, no extra page. If the organization doesn't exist or has nobody who can be impersonated, the page says so instead of quietly redirecting.

Seeing what the user sees while impersonating

  • Before: while impersonating, admin-only UI was still rendered — extra side-menu sections, debug tooltips, "Admin only" panels, EXPLAIN in the query editor, hidden regions. You couldn't tell what the customer actually sees.
  • After: the account menu gains a View as user entry next to Stop impersonating. Turning it on hides all of that, so the dashboard looks the way the impersonated user's does; Show admin UI turns it back. It's purely about what's displayed and disappears on its own when you stop impersonating. The ways out stay visible the whole time: the yellow impersonation border, Stop impersonating, the global admin shortcut, and the toggle itself.

How

The /@/orgs/:slug/* route becomes a full route module (loader + action + UI). The loader keeps its existing branches in the same order and still starts impersonation immediately for a same-origin navigation; otherwise it does a read-only organization lookup and returns data for a consent interstitial (no cookie set, no audit row written). The Form has no action, so it posts to the current URL and the org slug plus splat path reach the action unchanged; the action re-applies the same-origin check, rejects non-POST with 405, and calls the same shared helper the loader's fast path uses. The button uses the Button primitive's shortcut prop, and the form uses reloadDocument so every loader re-runs under the new cookie.

The view-as-user flag is a key on the existing impersonation cookie session, which is why it's automatically scoped to the impersonation session — clearImpersonationId unsets it alongside the impersonated user id. It's exposed on the root loader payload and on requireUser, read on the client through a new useIsViewingAsUser() hook, and folded into useHasAdminAccess() — which covers roughly twenty existing call sites at once. SideMenu's three direct admin reads now go through the same derived value. Server-side, a new hasAdminDisplayAccess(user) helper in session.server.ts replaces the ad-hoc user.admin || user.isImpersonating display derivations in the affected loaders; it is documented as display-only and no authorization gate was touched (getUserId, requireSuper/canSuper, the query access gate, the logs-page gate and the /admin routes are unchanged). A new POST /resources/impersonation/view-as resource route flips the flag, requires an authenticated user, no-ops when the caller isn't impersonating, and redirects with a full navigation.

Testing

  • New unit tests: apps/webapp/test/impersonationConsent.test.ts locks that resolving who a link would impersonate is read-only (no audit row) while the explicit POST does start impersonation, sets the cookie and preserves the destination path; plus the org-not-found / no-confirmed-member / deleted-org cases. apps/webapp/test/viewAsUser.test.ts covers the cookie helpers (on, off, never on without an impersonated user, dropped when impersonation is cleared) and hasAdminDisplayAccess. Testcontainers only, nothing mocked.
  • The existing same-origin navigation tests still pass.
  • pnpm run typecheck --filter webapp clean; pnpm run format and pnpm run lint clean.

Changelog

Admins opening an impersonation link from outside the dashboard now get a confirmation page instead of being bounced, and while impersonating they can switch to "View as user" to see the dashboard without any admin-only UI.

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
@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9d12b5f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

claude added 3 commits July 29, 2026 19:40
…module

The consent route exported `findImpersonationTarget` and
`startImpersonation` alongside its loader and action. Remix only strips
`loader`, `action` and `headers` from a route module when building the
browser bundle, so those extra exports kept `~/db.server` in the client
graph and the build failed with "Server-only module referenced by
client".

Move both helpers into `~/models/admin.server`, next to
`redirectWithImpersonation` and `clearImpersonation`, and import them
from the route. The route now only exports `loader`, `action` and its
component, so every `.server` import it has is removed for the client.
…ser toggle

The consent page's form had no `action`, so React Router resolved it to the
matched route's `pathnameBase`. Without `future.v3_relativeSplatPath` that
excludes the splat, so the form posted to `/@/orgs/<slug>`, the action saw an
empty splat, and the admin landed on the organization root instead of the deep
link the page had just named. The form now posts to an explicit absolute path.

The query string was dropped too. A `/@/runs/<id>` link redirects to a run path
carrying `?span=`, which selects the span to open, so both the displayed
destination and the final redirect now carry the incoming search.

The debug tooltip and the "Debug run" button still checked
`!hasAdminAccess && !isImpersonating`, which stays true with the toggle on, so
the two most visible admin-only affordances kept rendering. `useHasAdminAccess`
already folds in impersonation and the toggle, so the extra check is gone. The
replay dialog's region picker was likewise still keyed off raw impersonation and
now uses `hasAdminDisplayAccess`.

Also: gate the view-as-user route on a same-origin navigation like the other
impersonation routes, stop it flat-route-nesting under `resources.impersonation`
(the URL is unchanged), and log consent-page entry at info with only the
referer's origin, since it is expected for any link opened outside the app.
…n mock

The replay loader now derives the region picker's `isAdmin` from
`hasAdminDisplayAccess`, but this file's `vi.mock` of `~/services/session.server`
is a factory that replaces the whole module, and it only returned the three
functions the route graph used before. Accessing an export a factory mock does
not define throws, so every case in the file failed once the loader was reached:

  Error: [vitest] No "hasAdminDisplayAccess" export is defined on the
  "~/services/session.server" mock. Did you forget to return it from "vi.mock"?

The stub mirrors the real predicate rather than importing it, since pulling the
original module in would evaluate the server-only import graph this file
deliberately keeps out. The fixture user gains `isViewingAsUser: false`, so the
flag stays false exactly as it did before, and the presenter that reads it is
mocked out anyway.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant