feat(webapp): impersonation consent page and a view-as-user toggle - #4421
Draft
claude[bot] wants to merge 4 commits into
Draft
feat(webapp): impersonation consent page and a view-as-user toggle#4421claude[bot] wants to merge 4 commits into
claude[bot] wants to merge 4 commits into
Conversation
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
|
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Chris Arderne, Eric Allam · Slack thread
Before / After
Impersonation links opened from outside the dashboard
/@/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.Seeing what the user sees while impersonating
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). TheFormhas noaction, 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 theButtonprimitive'sshortcutprop, and the form usesreloadDocumentso 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 —
clearImpersonationIdunsets it alongside the impersonated user id. It's exposed on the root loader payload and onrequireUser, read on the client through a newuseIsViewingAsUser()hook, and folded intouseHasAdminAccess()— 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 newhasAdminDisplayAccess(user)helper insession.server.tsreplaces the ad-hocuser.admin || user.isImpersonatingdisplay 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/adminroutes are unchanged). A newPOST /resources/impersonation/view-asresource route flips the flag, requires an authenticated user, no-ops when the caller isn't impersonating, and redirects with a full navigation.Testing
apps/webapp/test/impersonationConsent.test.tslocks 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.tscovers the cookie helpers (on, off, never on without an impersonated user, dropped when impersonation is cleared) andhasAdminDisplayAccess. Testcontainers only, nothing mocked.pnpm run typecheck --filter webappclean;pnpm run formatandpnpm run lintclean.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.