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.

Author: @dezbah-duchicela PR: #6507 Stack: 2 of 2 Base: dezbah/login-as-server Head: dezbah/login-as-client State: open Files: 13 Diff: +973 / -14 Area: apps/client

What it adds

A staff-only “Login as user” entry in desktop and mobile sidebars, a searchable modal, and an amber session banner with an explicit exit.

What it fixes

Replaces free-text impersonation with an org-scoped picker so staff cannot accidentally match a similarly named user at another customer.

Safety model

Starting a session is gated by DEALOPS_INTERNAL_STAFF; ending a session is gated only by user.isLoginAsSession so the escape hatch always renders.

Test surface

Adds 17 Jest + Testing Library tests and brings in jest-environment-jsdom for component tests.

Sidebar entry
LoginAsModal
LoginAsBanner
Gating utilities
Jest/jsdom tests

1. Why this exists

Before
Prompt-based target selection
  • Staff entered a free-text id, member id, or name.
  • Server matched name substrings across every organization.
  • Ambiguous strings like dan could resolve to the most recently edited matching user at another customer.
  • The mistake became visible only after the impersonation session started.
After
Org-scoped picker
  • 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.
Stack dependency: this is PR 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

SidebarDesktop.tsx / SidebarMobile.tsx Adds optional onLoginAs prop and inserts “Login as user” before Logout when supplied.
LoginAsModal.tsx New Ant Design modal that loads candidates from login-as/users, searches by name and role, then posts { userId } to login-as.
LoginAsBanner.tsx New persistent amber banner naming the impersonated user and org, with an “End session” button calling end-login-as.
App.tsx / users.ts / types.tsx Wires modal state, sidebar callbacks, banner rendering, canStartLoginAsSession, and User.isLoginAsSession.
Component + utility tests Adds focused tests for picker states, stale-list regression, submit payload, banner error recovery, double-click suppression, and role gating.
package.json / pnpm-lock.yaml Adds 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

1
Gate

App.tsx computes canLoginAsUser from the loaded user.

2
Open

Desktop and mobile sidebars receive onLoginAs only when the gate passes.

3
Pick

The modal loads current-org users and searches against labels like Grace Hopper — User.

4
Start

Submit posts login-as with the selected userId, then navigates to /authenticate.

5
Exit

The banner posts end-login-as; success also routes through /authenticate.

Start gate

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.

Picker loading rules
  • On open: set loading, clear error, clear selection.
  • Before fetch returns: clear candidates and organizationName.
  • On failure: show an alert and render no selector.
  • On success: show an Ant Design Select with role-enriched labels.
Banner rules
  • Label: user.name ?? user.id.
  • Org: organization?.name when 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.
Testing note: the PR description says the new tests pass, client typecheck is clean, and lint is clean except existing-style any-typed catch params in App.tsx.

4. What it doesn’t change

Explicit non-goals

5. Risks / rollback / open questions

Risk

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.

Rollback

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.

Not verified

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.

Open question: the PR description says ending a session routes to /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.”