Login-as client UI lands in Dealops
This PR adds the client-side entry point, user picker, persistent impersonation banner, and Jest coverage for the login-as workflow.
A staff-only “Login as user” entry in desktop and mobile sidebars, a searchable modal, and an amber session banner with an explicit exit.
Replaces free-text impersonation with an org-scoped picker so staff cannot accidentally match a similarly named user at another customer.
Starting a session is gated by DEALOPS_INTERNAL_STAFF; ending a session is gated only by user.isLoginAsSession so the escape hatch always renders.
Adds 17 Jest + Testing Library tests and brings in jest-environment-jsdom for component tests.
1. Why this exists
- Staff entered a free-text id, member id, or name.
- Server matched name substrings across every organization.
- Ambiguous strings like
dancould resolve to the most recently edited matching user at another customer. - The mistake became visible only after the impersonation session started.
- Staff choose from active users in their current organization.
- Switching customers requires switching organizations through Stytch membership first.
- The selected payload is a concrete
userId, not a fuzzy search string. - A persistent banner keeps the impersonation state visible.
2 of 2. It calls the server routes added in #6506, so reviewers should inspect the server authorization and audit behavior there first.
2. What changes
onLoginAs prop and inserts “Login as user” before Logout when supplied.
login-as/users, searches by name and role, then posts { userId } to login-as.
end-login-as.
canStartLoginAsSession, and User.isLoginAsSession.
jest-environment-jsdom@30.4.1 because Jest 30 no longer bundles jsdom.
Review map
| Concern | Implementation | Reviewer focus |
|---|---|---|
| Entry point visibility | canStartLoginAsSession(user) returns true only for Role.DEALOPS_INTERNAL_STAFF. |
Confirm the client role is the right UX gate; server remains the security boundary. |
| Candidate list safety | Modal fetches GET /api/v1/login-as/users through the client api wrapper. |
Confirm server route scopes to current org, excludes self, and returns only valid active candidates in PR #6506. |
| Stale list regression | On every open, candidates and organization name are cleared before the request resolves. | Good guard: a failed reopen cannot leave old org users selectable. |
| Session exit | Banner renders from user.isLoginAsSession, not staff role. |
Important invariant: anyone inside an impersonation session must have a way out. |
| Duplicate audit events | ending state prevents repeated end-login-as posts. |
Good guard because each end request writes an audit event. |
3. How it works
App.tsx computes canLoginAsUser from the loaded user.
Desktop and mobile sidebars receive onLoginAs only when the gate passes.
The modal loads current-org users and searches against labels like Grace Hopper — User.
Submit posts login-as with the selected userId, then navigates to /authenticate.
The banner posts end-login-as; success also routes through /authenticate.
The new utility is intentionally small:
export function canStartLoginAsSession(user) {
return isInternalStaff(user);
}
The PR removes the old built-bundle blocker by not hiding the entry point behind import.meta.env.DEV.
- On open: set loading, clear error, clear selection.
- Before fetch returns: clear
candidatesandorganizationName. - On failure: show an alert and render no selector.
- On success: show an Ant Design
Selectwith role-enriched labels.
- Label:
user.name ?? user.id. - Org:
organization?.namewhen present. - End button disables while the request is pending.
- Failures call
addError, restore the button, and surface a recoverable error.
Test coverage added
| Test file | New checks |
|---|---|
LoginAsModal.test.tsx |
Loading spinner, loaded options with role labels, load errors, stale-list clearing on failed reopen, submit payload, disabled submit before selection. |
LoginAsBanner.test.tsx |
User/org display, missing org display, success callback, double-click suppression, failure recovery. |
loginAsGating.test.ts |
Only DEALOPS_INTERNAL_STAFF can start; admins, users, view-only users, acting-as-customer staff, and null users cannot. |
App.test.tsx |
Adds per-file jsdom environment and jest-dom setup so existing dashboard component tests run under Jest 30. |
any-typed catch params in App.tsx.
4. What it doesn’t change
- No server authorization logic is introduced here; that belongs to stacked PR
#6506. - No database schema, Prisma migration, or tRPC route is added in this client PR.
- No cross-organization search is exposed in the UI.
- No staff privilege is granted to
DEALOPS_INTERNAL_STAFF__AS_USER. - No Playwright coverage is added.
- No fix is attempted for the 8 unrelated pre-existing failing client suites called out in the PR description.
5. Risks / rollback / open questions
Client gate is not security.
The sidebar hides the action for non-staff, but all real authorization must remain server-side on login-as/users, login-as, and end-login-as.
Client-only revert path.
Reverting this PR removes the visible entry point, modal, banner, type field, tests, and jsdom dependency without requiring a schema rollback.
Manual gaps remain.
The PR notes mobile sidebar was not exercised at a mobile viewport, no deployed build was checked, and no Playwright coverage was added.
/login when the underlying Stytch session did not survive, but the diff routes to /authenticate and comments say that page revalidates and redirects to /login if needed. Confirm whether the desired contract is “navigate to /authenticate and let it decide” or “navigate directly to /login in that server-reported case.”